SysOps: approved_backup — 2026-08-16 11:01 UTC

This commit is contained in:
sysops
2026-08-16 11:01:58 +00:00
parent 22a17ed048
commit 1dd607b060
46 changed files with 3734 additions and 147 deletions
+111 -18
View File
@@ -2,6 +2,48 @@
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<style>
.reclame-kpi-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 640px) {
.reclame-kpi-row { grid-template-columns: 1fr; }
}
button.reclame-kpi-btn.kpi-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
appearance: none;
-webkit-appearance: none;
background: var(--bg-elevated, #0f172a);
border: 1px solid rgba(56,189,248,0.55);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
button.reclame-kpi-btn.kpi-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(56,189,248,0.18);
border-color: rgba(56,189,248,0.9);
}
button.reclame-kpi-btn.kpi-card:focus-visible {
outline: 2px solid rgba(56,189,248,0.75);
outline-offset: 2px;
}
button.reclame-kpi-btn.kpi-card.is-active {
border-color: #38bdf8;
background: rgba(56,189,248,0.08);
box-shadow: 0 0 0 1px rgba(56,189,248,0.25);
}
button.reclame-kpi-btn.kpi-card span { color: var(--text-secondary, #94a3b8); font-size: 0.85rem; }
button.reclame-kpi-btn.kpi-card strong { color: var(--text-primary, #f8fafc); font-size: 1.15rem; }
</style>
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
@@ -133,17 +175,23 @@
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="reclameBusy" x-text="reclameBusy ? '⏳ Bezig met ophalen…' : '↻ Ophalen van Reclamefolder.nl'"></button>
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
</div>
</div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem" role="group" aria-label="Reclame snelfilters">
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'ketens' }" @click="onReclameKpi('ketens')" title="Open ketens-filter">
<span>Ketens</span><strong x-text="reclameStats.chains||0"></strong>
</button>
<button type="button" class="kpi-card kpi-green clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'folders' }" @click="onReclameKpi('folders')" title="Toon folders">
<span>Folders</span><strong x-text="filteredPromos.length"></strong>
</button>
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'totaal' }" @click="onReclameKpi('totaal')" title="Toon alles / filters wissen">
<span>Totaal geladen</span><strong x-text="promos.length"></strong>
</button>
</div>
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
<select class="form-input" x-ref="chainFilter" x-model="reclameFilter.chain" @change="loadPromos()">
<option value="">Alle ketens</option>
<template x-for="c in reclameChains" :key="c">
<option :value="c" x-text="c"></option>
@@ -177,7 +225,7 @@
</a>
</template>
</div>
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<p x-show="!filteredPromos.length && !reclameBusy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
<form class="form-grid" @submit.prevent="addPromo">
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
@@ -397,10 +445,11 @@ function marketingHub() {
return {
tab: new URLSearchParams(location.search).get('tab') || 'live',
tabIndicatorTop: 0, tabIndicatorH: 44,
busy: false, dashboard: {}, socialAnalytics: '',
busy: false, reclameBusy: false, dashboard: {}, socialAnalytics: '',
regRows: { regelgeving: [], cbs: [], markt: [] },
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameKpi: 'folders',
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
@@ -457,7 +506,30 @@ function marketingHub() {
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
this.tabIndicatorH = btn.offsetHeight;
},
onReclameKpi(which) {
this.reclameKpi = which;
if (which === 'ketens') {
this.$nextTick(() => {
const el = this.$refs.chainFilter;
if (el) {
el.focus();
try { el.showPicker && el.showPicker(); } catch (e) {}
}
});
return;
}
if (which === 'folders') {
this.reclameFilter = { chain: '', q: '', folder_type: 'folder', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
return;
}
this.reclameFilter = { chain: '', q: '', folder_type: '', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
},
async loadReclameChains() {
try {
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
this.reclameChains = d.chains || [];
@@ -471,10 +543,15 @@ function marketingHub() {
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
const resp = await fetch('/api/retail/promo-campaigns?' + params);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
let items = (await resp.json()).items || [];
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
this.promos = items;
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
// Alleen auto-sync bij eerste lege load, niet tijdens handmatige sync (voorkomt nesting)
if (!items.length && !this.reclameBusy && !this.reclameFilter.q && !this.reclameFilter.chain && !this.reclameFilter.folder_type && !this.reclameFilter.valid_days) {
await this.refreshReclamefolders(false);
}
} catch (e) { this.promos = []; }
},
promoUrl(p) {
@@ -489,15 +566,26 @@ function marketingHub() {
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
},
async refreshReclamefolders(toast = true) {
this.busy = true;
if (this.reclameBusy) return;
this.reclameBusy = true;
try {
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
const resp = await fetch('/api/retail/reclamefolder/refresh', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: '{}',
});
const d = await resp.json().catch(() => ({}));
if (!resp.ok || d.ok === false) {
throw new Error(d.detail || d.error || ('HTTP ' + resp.status));
}
await this.loadPromos();
await this.loadReclameChains();
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
if (typeof d.synced === 'number') this.reclameStats.synced = d.synced;
if (toast) Cockpit.toast((d.synced || this.promos.length || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
} catch (e) {
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
} finally { this.busy = false; }
console.error('reclamefolder refresh', e);
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt: ' + (e.message || e), 'error');
} finally { this.reclameBusy = false; }
},
async addPromo() {
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
@@ -545,13 +633,18 @@ function marketingHub() {
},
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
async refreshAll(toast) {
this.busy = true;
const silent = toast === false;
if (!silent) this.busy = true;
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
if (this.tab === 'reclame' && !this.reclameBusy) {
await this.loadPromos();
await this.loadReclameChains();
}
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
if (!silent) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { if (!silent) this.busy = false; }
},
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },