SysOps: deploy-all — 2026-06-09 10:41 UTC

This commit is contained in:
sysops
2026-06-09 10:41:13 +00:00
parent 69fe67cc0e
commit 21ea3a2c81
82 changed files with 8906 additions and 981 deletions
+221 -49
View File
@@ -1,8 +1,11 @@
{% extends "base.html" %}
{% 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/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
<script src="/static/js/agent-avatars.js?v=3"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
@@ -11,6 +14,15 @@
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
<div id="viz-agents-alt" class="viz-alt"></div>
</div>
</div>
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
@@ -19,6 +31,10 @@
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
</button>
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
<span class="vtab-icon"></span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
</nav>
<div class="vtabs-content">
@@ -27,54 +43,41 @@
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">orchestrator</div></div></div><div class="hm-neo-kpi-value">CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">Co-CEO · takenverdeler</div></div></div><div class="hm-neo-kpi-value" style="font-size:1.1rem">Co-CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</div>
<div class="agents-neo-grid">
<template x-for="s in souls" :key="s.agent_key">
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
<div class="hm-neo-kpi-top">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(s.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
<span class="agent-status-dot" x-show="s.is_active !== false"></span>
</div>
<div style="flex:1">
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
</div>
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
<div class="agent-char-wrap" :class="s.agent_key"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-status-pill" :class="statusClass(s)">
<span class="dot"></span>
<span x-text="statusLabel(s)"></span>
</span>
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
<p class="agent-role-desc" x-text="(s.responsibilities || '').substring(0,120)"></p>
<div class="agent-meta-row">
<small x-text="(s.event_count||0) + ' events'"></small>
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small>
<button type="button" class="agent-events-link"
@click.stop="openEvents(s.agent_key, s.display_name)"
x-text="(s.event_count||0) + ' events'"
:title="'Bekijk wat ' + s.display_name + ' heeft gedaan'"></button>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
<div class="hm-eq" style="margin-top:0.5rem"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</article>
</template>
</div>
<div class="agent-soul-editor" x-show="selected" x-cloak>
<div class="agent-editor-header">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(selected?.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
</div>
<div class="agent-char-wrap large" :class="selected?.agent_key"
:data-agent-avatar="selected?.agent_key"
:data-agent-color="selected?.avatar_color || '#1e3a5f'"
x-effect="selected && $nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, selected.agent_key, selected.avatar_color))"></div>
<div style="flex:1">
<h2><span x-text="selected?.display_name"></span><span x-text="selected?.role_title"></span></h2>
<p style="margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text="selected?.responsibilities"></p>
@@ -87,9 +90,16 @@
<label>Rol titel<input class="form-input" x-model="form.role_title"></label>
<label>Functie & werkzaamheden<textarea class="form-input" rows="3" x-model="form.responsibilities"></textarea></label>
<label>Soul.md (karakter, tone of voice, regels)<textarea x-model="form.soul_md"></textarea></label>
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-top:1rem">Recente activiteit</h3>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin:0">Recente activiteit</h3>
<button type="button" class="agent-events-link" x-show="selected" @click="openEvents(selected.agent_key, selected.display_name)">Alle events →</button>
</div>
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
<div class="hub-feed-item"><span x-text="ev.created_at?.substring(0,16)"></span> <span x-text="ev.title||ev.event_type"></span></div>
<button type="button" class="hub-feed-item agent-event-clickable" @click="openEvents(selected.agent_key, selected.display_name, ev.id)">
<span class="agent-event-time" x-text="formatEventTime(ev.created_at)"></span>
<span class="agent-event-title" x-text="ev.title||ev.event_type"></span>
<span class="badge badge-sm" x-text="ev.status||'—'"></span>
</button>
</template>
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
</div>
@@ -98,7 +108,7 @@
<section x-show="tab==='mesh'" x-cloak>
<div class="panel">
<h2 style="margin-top:0;">Netwerk</h2>
<p class="page-tip" style="margin-top:.25rem;">Communication mesh met Herman als centrale Co-CEO hub.</p>
<p class="page-tip" style="margin-top:.25rem;">Agents onderaan → Herman (Co-CEO) → CEO &amp; CTO. Pulse = actieve communicatie.</p>
<div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
</div>
@@ -110,11 +120,82 @@
</div>
</div>
</section>
<section x-show="tab==='approvals'" x-cloak x-data="approvalsPanel()" x-init="load()">
<div class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem">
<div>
<h2 style="margin:0;">Approval queue</h2>
<p class="page-tip">Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.</p>
</div>
<button class="btn btn-sm btn-pulse" @click="load()">↻ Verversen</button>
</div>
<table class="data-table">
<thead><tr><th>#</th><th>Agent</th><th>Actie</th><th>Titel</th><th>Status</th><th>Tijd</th><th></th></tr></thead>
<tbody>
<template x-for="r in items" :key="r.id">
<tr>
<td x-text="r.id"></td>
<td x-text="r.agent_key"></td>
<td x-text="r.action_type"></td>
<td x-text="r.title"></td>
<td><span class="badge" :class="r.status==='pending'?'badge-warn':'badge-positive'" x-text="r.status"></span></td>
<td x-text="(r.created_at||'').substring(0,16).replace('T',' ')"></td>
<td>
<template x-if="r.status==='pending'">
<span>
<button class="btn btn-sm btn-approve" @click="approve(r.id)"></button>
<button class="btn btn-sm btn-reject" @click="reject(r.id)"></button>
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
<p x-show="!items.length" class="muted">Geen approval requests.</p>
</div>
</section>
</div>
</div>
<div class="agent-events-backdrop" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<aside class="agent-events-drawer" :class="{ open: eventsOpen }" x-cloak>
<header class="agent-events-head">
<div>
<h3 x-text="eventsAgentName || 'Agent'"></h3>
<p class="muted" x-text="eventsList.length + ' activiteiten gelogd'"></p>
</div>
<button type="button" class="btn btn-sm" @click="closeEvents()"></button>
</header>
<div class="agent-events-body" x-show="!eventsLoading">
<template x-for="ev in eventsList" :key="ev.id">
<article class="agent-event-card" :class="{ expanded: selectedEvent?.id === ev.id }" @click="toggleEventDetail(ev)">
<div class="agent-event-card-top">
<time x-text="formatEventTime(ev.created_at)"></time>
<span class="badge badge-sm" :class="eventStatusClass(ev.status)" x-text="ev.status || 'completed'"></span>
</div>
<strong x-text="ev.title || ev.event_type"></strong>
<div class="agent-event-meta">
<span x-text="ev.event_type"></span>
<span x-show="ev.channel" x-text="'· ' + ev.channel"></span>
</div>
<div class="agent-event-detail" x-show="selectedEvent?.id === ev.id" x-cloak>
<p x-show="ev.body" x-text="ev.body"></p>
<pre x-show="!ev.body && ev.metadata && Object.keys(ev.metadata||{}).length" x-text="JSON.stringify(ev.metadata, null, 2)"></pre>
<p x-show="!ev.body && !(ev.metadata && Object.keys(ev.metadata||{}).length)" class="muted">Geen extra details voor dit event.</p>
</div>
</article>
</template>
<p x-show="!eventsList.length && !eventsLoading" class="muted" style="padding:1rem">Nog geen events voor deze agent.</p>
</div>
<p x-show="eventsLoading" class="muted" style="padding:1.5rem">Events laden…</p>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js"></script>
<script>
function agentsSoulHub() {
@@ -123,22 +204,67 @@ function agentsSoulHub() {
tabIndicatorTop: 0,
tabIndicatorH: 40,
meshLoaded: false,
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false,
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
avatarStyle(key) {
const m = {
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob',
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl',
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short',
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses',
};
return m[key] || 'style-male-short';
statusClass(s) {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval' || st === 'pending') return 'busy';
if (st === 'error' || st === 'rejected') return 'error';
if (st === 'completed' || (s.events_6h > 0)) return 'active';
return 'idle';
},
formatEventTime(ts) {
return (ts || '').substring(0, 16).replace('T', ' ');
},
eventStatusClass(status) {
const s = (status || '').toLowerCase();
if (s === 'pending' || s === 'needs_approval') return 'badge-warn';
if (s === 'error' || s === 'rejected') return 'badge-negative';
if (s === 'approved' || s === 'completed') return 'badge-positive';
return '';
},
async openEvents(key, name, focusId) {
if (!key) return;
this.eventsAgentKey = key;
this.eventsAgentName = name || key;
this.eventsOpen = true;
this.eventsLoading = true;
this.selectedEvent = null;
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=50').then(x => x.json());
this.eventsList = r.items || [];
if (focusId) {
const hit = this.eventsList.find(e => e.id === focusId);
if (hit) this.selectedEvent = hit;
}
} catch (e) { this.eventsList = []; }
this.eventsLoading = false;
},
closeEvents() {
this.eventsOpen = false;
this.selectedEvent = null;
},
toggleEventDetail(ev) {
this.selectedEvent = this.selectedEvent?.id === ev.id ? null : ev;
},
statusLabel(s) {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval') return 'Wacht op goedkeuring';
if (st === 'pending') return 'In wachtrij';
if (st === 'completed') return 'Klaar';
if (st === 'error') return 'Fout';
if (s.current_task) return 'Bezig';
return 'Standby';
},
hasHair(key) { return !!key; },
hasGlasses(key) { return ['herman','research','finance','legal'].includes(key); },
async init() {
await this.load();
await this.loadPendingCount();
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn);
@@ -159,6 +285,13 @@ function agentsSoulHub() {
url.searchParams.set('tab', next);
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
if (next === 'approvals') this.loadPendingCount();
},
async loadPendingCount() {
try {
const r = await fetch('/api/agents/approvals?status=pending').then(x => x.json());
this.pendingCount = (r.items || []).length;
} catch (e) { this.pendingCount = 0; }
},
mountMesh() {
if (this.meshLoaded || !window.AgentsMesh) return;
@@ -169,6 +302,22 @@ function agentsSoulHub() {
const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || [];
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
this.renderViz(window.__vizMode || 'neo-bars');
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
},
renderViz(mode) {
if (!window.VizEngine) return;
const rows = (this.souls || []).map(s => ({
label: s.agent_key,
value: s.event_count || 0,
raw: s.event_count || 0,
})).sort((a, b) => b.raw - a.raw).slice(0, 12);
if (!rows.length) rows.push({ label: 'geen', value: 0, raw: 0 });
VizEngine.renderFromRows(mode,
document.getElementById('chart-agents-viz'),
document.getElementById('viz-agents-alt'),
'agents-page', 'Events per agent', rows, 'hm-eq-lime'
);
},
async select(key) {
const r = await fetch('/api/agents/souls/' + key).then(x => x.json());
@@ -188,5 +337,28 @@ function agentsSoulHub() {
}
};
}
function approvalsPanel() {
return {
items: [],
async load() {
try {
const r = await fetch('/api/agents/approvals?limit=50').then(x => x.json());
this.items = r.items || [];
} catch (e) { this.items = []; }
},
async approve(id) {
await Cockpit.api('/api/agents/approvals/' + id + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd — agent mag query uitvoeren', 'success');
await this.load();
},
async reject(id) {
const reason = prompt('Reden afwijzing (optioneel):') || '';
await Cockpit.api('/api/agents/approvals/' + id + '/reject', { method: 'POST', body: JSON.stringify({ reason }) });
Cockpit.toast('Afgewezen', 'info');
await this.load();
},
};
}
</script>
{% endblock %}
+1 -1
View File
@@ -71,5 +71,5 @@
{% endblock %}
{% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=1"></script>
<script src="/static/js/analytics.js?v=2"></script>
{% endblock %}
+45 -27
View File
@@ -17,6 +17,7 @@
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=3" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block extra_head %}{% endblock %}
@@ -30,46 +31,48 @@
</div>
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}">Herman</a>
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Agents</div>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
<div class="app-sidebar-label" data-i18n="nav.agents">Agents</div>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">System</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}">IT Ops</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
<div class="app-sidebar-label" data-i18n="nav.system">Systeem</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
</div>
</aside>
@@ -79,6 +82,11 @@
<header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen"></button>
<span class="page-tip">Foodlinkk</span>
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
<option value="nl">NL</option>
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate"></strong> · <span x-text="clockTime"></span></div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
@@ -91,11 +99,15 @@
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/cockpit.js"></script>
<script src="/static/js/i18n.js?v=2"></script>
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script>
function appShell() {
return {
locale: 'nl',
drawerOpen: false,
navOpen: false,
showInstall: false,
@@ -103,10 +115,16 @@
clockDate: '—',
clockTime: '—',
_offset: 0,
initApp() {
async initApp() {
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock();
this.initPwa();
},
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
},
async initClock() {
try {
const r = await fetch('/api/server-time').then(x => x.json());
+527 -42
View File
@@ -1,54 +1,539 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="clientsCrud()">
<header class="page-header"><h1>Clients</h1>
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
<button class="btn btn-primary" @click="openModal()">Add client</button>
<div class="kanban">
{% set stages = ['intake','discovery','proposal','active','churned'] %}
{% for st in stages %}
<div class="kanban-col"><h3>{{ st }}</h3>
{% for c in clients if c.stage == st %}
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('clients.title')">CRM · Klanten</h1>
<p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/deals">Deals →</a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span>Totaal klanten</span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span>Actief</span><strong x-text="stats.active||0"></strong></div>
<div class="clients-kpi"><span>MRR schatting</span><strong x-text="fmtEur(stats.mrr_total)"></strong></div>
<div class="clients-kpi"><span>Pipeline</span><strong x-text="fmtEur(stats.pipeline_value)"></strong></div>
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
</div>
</div>
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'" x-text="$t('clients.tab_kanban')">Kanban</button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('clients.tab_list')">Lijst</button>
<button class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; searchStores()" x-text="$t('clients.tab_stores')">Supermarkten</button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openClientModal()" x-text="'+ ' + $t('clients.new')">+ Nieuwe klant</button>
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
</div>
</div>
<!-- Kanban -->
<div x-show="tab==='kanban'" x-cloak>
<div class="clients-kanban">
<template x-for="st in stages" :key="st.id">
<div class="clients-kanban-col">
<h3><span x-text="st.label"></span> <span class="count" x-text="stageCount(st.id)"></span></h3>
<template x-for="c in clientsByStage(st.id)" :key="c.id">
<div class="clients-card" :class="'stage-'+c.stage" @click="openDetail(c)">
<strong x-text="c.name"></strong>
<div class="clients-card-meta">
<span x-show="c.contact" x-text="c.contact"></span>
<span x-show="c.sector"> · <span x-text="c.sector"></span></span>
</div>
<div class="clients-card-meta" x-show="c.email" x-text="c.email"></div>
<div class="clients-card-tags">
<span class="clients-tag mrr" x-show="c.mrr_estimate" x-text="'€'+Number(c.mrr_estimate).toLocaleString('nl-NL')+'/mnd'"></span>
<span class="clients-tag" x-show="c.store_count" x-text="c.store_count+' filialen'"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- List -->
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek naam, contact, e-mail…" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterStage" @change="applyFilter()">
<option value="">Alle stages</option>
<template x-for="st in stages" :key="'f'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
<select x-model="filterSector" @change="applyFilter()">
<option value="">Alle sectoren</option>
<template x-for="s in sectorOptions" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th>Naam</th><th>Contact</th><th>E-mail</th><th>Sector</th><th>Stage</th><th>MRR</th><th>Filialen</th><th></th>
</tr></thead>
<tbody>
<template x-for="c in filtered" :key="'l'+c.id">
<tr class="clickable-row" @click="openDetail(c)">
<td><strong x-text="c.name"></strong></td>
<td x-text="c.contact||'—'"></td>
<td x-text="c.email||'—'"></td>
<td x-text="c.sector||'—'"></td>
<td><span class="badge" x-text="stageLabel(c.stage)"></span></td>
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
<td x-text="c.store_count||0"></td>
<td>
<button class="btn btn-sm" @click.stop="editClient(c)">Bewerk</button>
<button class="btn btn-sm btn-reject" @click.stop="removeClient(c.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
<p class="clients-empty" x-show="!filtered.length">Geen klanten gevonden.</p>
</div>
<!-- Supermarkten -->
<div x-show="tab==='stores'" x-cloak>
<p class="page-tip" style="margin-bottom:0.75rem">Zoek een filiaal, vul ontbrekende data aan (contact, halal, manager) en koppel aan een klant.</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek filiaal, keten, stad…" x-model="storeQ" @keyup.enter="searchStores()" />
<select x-model="storeChain" @change="searchStores()">
<option value="">Alle ketens</option>
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
</select>
<select x-model="storeProvince" @change="searchStores()">
<option value="">Alle provincies</option>
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
</select>
<button class="btn btn-sm btn-pulse" @click="searchStores()" :disabled="storeBusy">Zoeken</button>
</div>
<div class="store-search-panel">
<div>
<div class="store-list">
<template x-for="s in storeResults" :key="s.id">
<div class="store-list-item" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="(s.city||'') + (s.province ? ', '+s.province : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag" x-show="s.partnership_status && s.partnership_status!=='none'" x-text="s.partnership_status"></span>
<span class="clients-tag" x-show="s.halal_certified">Halal ✓</span>
</div>
</div>
</template>
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
</div>
<p class="muted" style="font-size:0.72rem;margin-top:0.35rem" x-text="storeResults.length+' filialen · max 80'"></p>
</div>
<div class="store-edit-panel" x-show="selectedStore">
<h3 x-text="(selectedStore?.chain||'') + ' · ' + (selectedStore?.name||'')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Adres</span>
<input class="form-input" x-model="storeForm.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="storeForm.postcode" /></label>
<label><span class="filter-label">Stad</span>
<input class="form-input" x-model="storeForm.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="storeForm.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="storeForm.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="storeForm.email" /></label>
<label><span class="filter-label">Website</span>
<input class="form-input" x-model="storeForm.website" /></label>
<label><span class="filter-label">Manager</span>
<input class="form-input" x-model="storeForm.manager_name" /></label>
<label><span class="filter-label">Partnership</span>
<select class="form-input" x-model="storeForm.partnership_status">
<option value="none">Geen</option><option value="prospect">Prospect</option>
<option value="active">Actief</option><option value="paused">Gepauzeerd</option>
</select></label>
<label><span class="filter-label">Halal certificerder</span>
<input class="form-input" x-model="storeForm.halal_certifier" placeholder="HQC, OSM…" /></label>
</div>
<div class="store-checks">
<label><input type="checkbox" x-model="storeForm.halal_certified" /> Halal gecertificeerd</label>
<label><input type="checkbox" x-model="storeForm.has_halal_section" /> Halal schap</label>
</div>
<div class="clients-form-grid" style="margin-top:0.75rem">
<label class="full"><span class="filter-label">Koppel aan klant</span>
<select class="form-input" x-model="linkForm.client_id">
<option value="">— Selecteer klant —</option>
<template x-for="c in clients" :key="'lc'+c.id">
<option :value="c.id" x-text="c.name + ' ('+stageLabel(c.stage)+')'"></option>
</template>
</select></label>
<label><span class="filter-label">Relatie</span>
<select class="form-input" x-model="linkForm.relationship_type">
<option value="prospect">Prospect</option><option value="partner">Partner</option>
<option value="supplier">Leverancier</option><option value="competitor">Concurrent</option>
</select></label>
<label><span class="filter-label">Notities koppeling</span>
<input class="form-input" x-model="linkForm.notes" placeholder="Optioneel" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary btn-sm" @click="saveStore()" :disabled="storeBusy">Opslaan filiaal</button>
<button class="btn btn-sm btn-pulse-green" @click="linkStore()" :disabled="!linkForm.client_id">Koppel klant</button>
<a class="btn btn-sm" :href="'/retail?store='+selectedStore?.id" target="_blank">Retail 360 →</a>
</div>
</div>
<div class="store-edit-panel clients-empty" x-show="!selectedStore && !storeBusy">
Selecteer een filiaal links om data aan te vullen.
</div>
</div>
</div>
<!-- Client modal -->
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="clientForm.id ? 'Klant bewerken' : 'Nieuwe klant'"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Bedrijfsnaam *</span>
<input class="form-input" x-model="clientForm.name" placeholder="Chai N Masala B.V." /></label>
<label><span class="filter-label">Contactpersoon</span>
<input class="form-input" x-model="clientForm.contact" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" type="email" x-model="clientForm.email" /></label>
<label><span class="filter-label">Sector</span>
<select class="form-input" x-model="clientForm.sector">
<option value="">— Kies sector —</option>
<template x-for="s in sectorOptions" :key="'s'+s"><option :value="s" x-text="s"></option></template>
</select></label>
<label><span class="filter-label">Stage</span>
<select class="form-input" x-model="clientForm.stage">
<template x-for="st in stages" :key="'cs'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select></label>
<label><span class="filter-label">MRR schatting (€/mnd)</span>
<input class="form-input" type="number" min="0" step="100" x-model="clientForm.mrr_estimate" /></label>
<label class="full"><span class="filter-label">Notities</span>
<textarea class="form-input" rows="4" x-model="clientForm.notes" placeholder="Context, afspraken, next steps…"></textarea></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="saveClient()" :disabled="!clientForm.name.trim()">Opslaan</button>
<button class="btn btn-secondary" @click="clientModal=false">Annuleren</button>
<button class="btn btn-reject" x-show="clientForm.id" @click="removeClient(clientForm.id); clientModal=false">Verwijderen</button>
</div>
</div>
</div>
<!-- Store create modal -->
<div class="modal" :class="{ open: storeCreateModal }" @click.self="storeCreateModal=false">
<div class="modal-card" style="max-width:560px">
<h3>Nieuw filiaal toevoegen</h3>
<div class="clients-form-grid">
<label><span class="filter-label">Keten *</span>
<input class="form-input" x-model="newStore.chain" list="chain-list" placeholder="Jumbo, AH, Lidl…" /></label>
<datalist id="chain-list"><template x-for="ch in chains" :key="'dl'+ch"><option :value="ch"></option></template></datalist>
<label><span class="filter-label">Naam *</span>
<input class="form-input" x-model="newStore.name" placeholder="Jumbo Bos en Lommer" /></label>
<label class="full"><span class="filter-label">Adres *</span>
<input class="form-input" x-model="newStore.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="newStore.postcode" /></label>
<label><span class="filter-label">Stad *</span>
<input class="form-input" x-model="newStore.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="newStore.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="newStore.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="newStore.email" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="createStore()" :disabled="!newStore.name||!newStore.chain||!newStore.city">Toevoegen</button>
<button class="btn btn-secondary" @click="storeCreateModal=false">Annuleren</button>
</div>
</div>
</div>
<!-- Detail drawer -->
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div>
<h2 x-text="detail?.client?.name||''"></h2>
<p class="muted" x-text="stageLabel(detail?.client?.stage)+' · '+(detail?.client?.sector||'Geen sector')"></p>
</div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.client">
<div>
<div class="clients-card-meta">
<div x-show="detail.client.contact"><strong>Contact:</strong> <span x-text="detail.client.contact"></span></div>
<div x-show="detail.client.email"><strong>E-mail:</strong> <span x-text="detail.client.email"></span></div>
<div x-show="detail.client.mrr_estimate"><strong>MRR:</strong> <span x-text="fmtEur(detail.client.mrr_estimate)"></span></div>
<div x-show="detail.client.notes" style="margin-top:0.5rem;white-space:pre-wrap" x-text="detail.client.notes"></div>
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
<select class="form-input" style="width:auto" x-model="detail.client.stage" @change="quickStage(detail.client)">
<template x-for="st in stages" :key="'ds'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
</div>
<div class="clients-drawer-section">
<h4>Deals (<span x-text="(detail.deals||[]).length"></span>)</h4>
<template x-for="d in detail.deals||[]" :key="d.id">
<div class="clients-card" style="cursor:default">
<strong x-text="d.title"></strong>
<div class="clients-card-meta" x-text="fmtEur(d.value)+' · '+d.stage"></div>
</div>
</template>
<p class="muted" x-show="!(detail.deals||[]).length">Nog geen deals — <a href="/deals">voeg toe</a></p>
</div>
<div class="clients-drawer-section">
<h4>Gekoppelde filialen (<span x-text="(detail.stores||[]).length"></span>)</h4>
<template x-for="s in detail.stores||[]" :key="s.id">
<div class="clients-card" style="cursor:default">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="s.city+(s.province?', '+s.province:'')"></div>
<span class="clients-tag" x-text="s.relationship_type||s.partnership_status"></span>
</div>
</template>
<button class="btn btn-sm" style="margin-top:0.5rem" @click="tab='stores'; drawer=false">+ Filiaal koppelen</button>
</div>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsCrud() {
function clientsHub() {
const STAGES = [
{ id: 'intake', label: 'Intake' },
{ id: 'discovery', label: 'Verkenning' },
{ id: 'proposal', label: 'Voorstel' },
{ id: 'active', label: 'Actief' },
{ id: 'churned', label: 'Verloren' },
];
const SECTORS = [
'Retail', 'Supermarkt', 'Groothandel', 'Horeca', 'Food service',
'Spices & Tea', 'Kant-en-klaar', 'Import/Export', 'Private label', 'Overig',
];
return {
modal: false, drawer: false, detail: '',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
async save() {
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
location.reload();
tab: 'kanban',
busy: false,
storeBusy: false,
clientModal: false,
storeCreateModal: false,
drawer: false,
stages: STAGES,
sectorOptions: SECTORS,
stats: {},
clients: [],
filtered: [],
filterQ: '',
filterStage: '',
filterSector: '',
detail: null,
clientForm: {},
storeResults: [],
selectedStore: null,
storeForm: {},
linkForm: { client_id: '', relationship_type: 'prospect', notes: '' },
storeQ: '',
storeChain: '',
storeProvince: '',
chains: [],
provinces: [],
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
fmtEur(v) {
if (v == null || v === '') return '—';
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
},
stageLabel(id) {
return (STAGES.find(s => s.id === id) || {}).label || id || '—';
},
stageCount(id) { return this.clients.filter(c => c.stage === id).length; },
clientsByStage(id) { return this.clients.filter(c => c.stage === id); },
async init() { await this.loadAll(); },
async loadAll() {
this.busy = true;
try {
const [statsR, clientsR, filtersR] = await Promise.all([
Cockpit.api('/clients/stats'),
Cockpit.api('/clients'),
fetch('/api/retail/filters').then(r => r.json()).catch(() => ({})),
]);
this.stats = statsR.stats || {};
this.clients = clientsR.items || [];
this.chains = (filtersR.chains || []).map(x => x.chain || x).filter(Boolean);
this.provinces = (filtersR.provinces || []).map(x => x.province || x).filter(Boolean);
this.applyFilter();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.clients.filter(c => {
if (this.filterStage && c.stage !== this.filterStage) return false;
if (this.filterSector && c.sector !== this.filterSector) return false;
if (!q) return true;
return [c.name, c.contact, c.email, c.sector, c.notes].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openClientModal() {
this.clientForm = { id: null, name: '', contact: '', email: '', stage: 'intake', sector: '', mrr_estimate: '', notes: '' };
this.clientModal = true;
},
editClient(c) {
this.clientForm = {
id: c.id, name: c.name || '', contact: c.contact || '', email: c.email || '',
stage: c.stage || 'intake', sector: c.sector || '', mrr_estimate: c.mrr_estimate || '',
notes: c.notes || '',
};
this.clientModal = true;
this.drawer = false;
},
async saveClient() {
const body = {
name: this.clientForm.name.trim(),
contact: this.clientForm.contact || null,
email: this.clientForm.email || null,
stage: this.clientForm.stage,
sector: this.clientForm.sector || null,
mrr_estimate: this.clientForm.mrr_estimate ? parseFloat(this.clientForm.mrr_estimate) : null,
notes: this.clientForm.notes || null,
};
try {
if (this.clientForm.id) {
await Cockpit.api('/clients/' + this.clientForm.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('Klant opgeslagen', 'success');
this.clientModal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async removeClient(id) {
if (!Cockpit.confirmDelete('Klant definitief verwijderen?')) return;
try {
await Cockpit.api('/clients/' + id, { method: 'DELETE' });
Cockpit.toast('Klant verwijderd', 'info');
this.drawer = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async openDetail(c) {
try {
const d = await Cockpit.api('/clients/' + c.id + '/detail');
this.detail = d;
this.drawer = true;
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async quickStage(c) {
const body = {
name: c.name, contact: c.contact, email: c.email, stage: c.stage,
sector: c.sector, mrr_estimate: c.mrr_estimate, notes: c.notes,
};
try {
await Cockpit.api('/clients/' + c.id, { method: 'PUT', body: JSON.stringify(body) });
Cockpit.toast('Stage bijgewerkt', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async searchStores() {
this.storeBusy = true;
try {
const params = new URLSearchParams({ limit: '80', sort: 'name' });
if (this.storeQ) params.set('q', this.storeQ);
if (this.storeChain) params.set('chain', this.storeChain);
if (this.storeProvince) params.set('province', this.storeProvince);
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
this.storeResults = r.items || [];
if (this.storeResults.length && !this.selectedStore) {
await this.selectStore(this.storeResults[0].id);
}
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async selectStore(id) {
try {
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
this.selectedStore = r;
this.storeForm = {
address: r.address || '', postcode: r.postcode || '', city: r.city || '',
province: r.province || '', phone: r.phone || '', email: r.email || '',
website: r.website || '', manager_name: r.manager_name || r.profile_manager || '',
partnership_status: r.partnership_status || 'none',
halal_certified: !!r.halal_certified, has_halal_section: !!r.has_halal_section,
halal_certifier: r.halal_certifier || '',
};
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async saveStore() {
if (!this.selectedStore) return;
this.storeBusy = true;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.storeForm),
}).then(r => { if (!r.ok) throw new Error('Opslaan mislukt'); return r.json(); });
Cockpit.toast('Filiaaldata opgeslagen', 'success');
await this.selectStore(this.selectedStore.id);
await this.searchStores();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async linkStore() {
if (!this.selectedStore || !this.linkForm.client_id) return;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id + '/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: parseInt(this.linkForm.client_id, 10),
relationship_type: this.linkForm.relationship_type,
partnership_status: this.storeForm.partnership_status !== 'none' ? this.storeForm.partnership_status : 'prospect',
notes: this.linkForm.notes || null,
}),
}).then(r => { if (!r.ok) throw new Error('Koppelen mislukt'); return r.json(); });
Cockpit.toast('Filiaal gekoppeld aan klant', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
openStoreCreate() {
this.newStore = { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' };
this.storeCreateModal = true;
},
async createStore() {
try {
const r = await fetch('/api/retail/supermarkets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newStore),
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
Cockpit.toast('Filiaal toegevoegd', 'success');
this.storeCreateModal = false;
await this.searchStores();
if (r.item?.id) await this.selectStore(r.item.id);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async setStage(id, stage, name, email, notes) {
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
Cockpit.toast('Stage updated', 'success'); location.reload();
}
};
}
</script>
+151 -45
View File
@@ -2,14 +2,25 @@
{% 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/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=8" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero">
<h1>Herman · Command Center <span class="live-badge">Live</span></h1>
<p class="hero-sub">CEO overzicht · klanten · briefing · retail · alles op een rij</p>
<p class="hero-sub">CEO overzicht · trending retail · briefing · platform links</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
</div>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
@@ -24,6 +35,7 @@
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-sm" @click="editLayout = !editLayout" x-text="editLayout ? 'Klaar ordenen' : '⋮⋮ Ordenen'"></button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
@@ -32,29 +44,38 @@
</div>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="briefing-dashboard" class="hm-dash-layout">
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · CEO KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's</h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="executive">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Executive</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database · geen RSS — zie Marketing Hub</small></div>
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
<div id="briefing-executive-summary"></div>
</div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section>
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p>
</div>
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
<h4>📋 Herman activiteitenlog</h4>
<ul id="briefing-activity-log" class="hm-exec-list" style="margin:0;padding-left:1.1rem;font-size:0.82rem"></ul>
</div>
<div class="briefing-horizon-col">
<div class="horizon-card short">
<h4>⚡ Korte termijn</h4>
@@ -68,12 +89,14 @@
</div>
</section>
<section class="hm-dash-section">
<section class="hm-dash-section hm-widget-draggable" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col">
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Top retail kansen</h4><small><a href="/retail">Retail 360</a></small></div>
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub</a></small></div>
<div id="briefing-retail"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
@@ -86,72 +109,153 @@
</div>
</section>
<section class="hm-dash-section hm-dash-full">
<h3 class="hm-section-title">Data & analytics</h3>
<section class="hm-dash-section hm-dash-full hm-dash-analytics hm-widget-draggable" data-widget="analytics">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Analytics</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-panel">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div>
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div>
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small id="viz-label-agents">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents"></canvas></div>
<div id="viz-agents-alt" class="viz-alt"></div>
</div>
</div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="approvals" id="widget-approvals">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Goedkeuringen</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen <span class="live-badge" id="approvals-pending-badge" style="display:none">0 open</span></h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt dagelijks toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>. Keur goed → actie wordt automatisch uitgevoerd.</p>
<div id="dash-approvals-list"></div>
</div>
</section>
<div class="hub-grid hm-dash-feed hm-widget-draggable" data-widget="feed">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Feed</div>
<section class="panel">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
</section>
<div class="hub-grid hm-dash-feed">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Wacht op OK</h2>
{% for item in approvals[:8] %}
<div class="approval-item"><span class="badge">{{ item.agent_name }}</span> {{ item.title or item.event_type }}</div>
{% else %}<p class="empty-state">Geen goedkeuringen 🎉</p>{% endfor %}
</section>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/briefing-charts.js?v=8"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=1"></script>
<script src="/static/js/briefing-charts.js?v=11"></script>
<script>
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() {
return {
loading: false, loadingMsg: 'Herman werkt…',
loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
_pollStop: null, _wsStop: null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
async init() {
window._dashboardVizMode = this._vizMode;
const prefs = await DashboardLayout.loadPrefs();
this._vizMode = prefs.global_viz_mode || 'neo-bars';
window._dashboardVizMode = this._vizMode;
DashboardLayout.applyOrder(document.getElementById('briefing-dashboard'), prefs.dashboard_layout || []);
DashboardLayout.initDrag(document.getElementById('briefing-dashboard'), (order) => DashboardLayout.saveLayout(order));
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
this._vizMode = mode;
window._dashboardVizMode = mode;
await DashboardLayout.saveViz(mode);
document.getElementById('viz-label-sentiment').textContent = mode;
document.getElementById('viz-label-agents').textContent = mode;
if (this._stats) BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, mode);
});
await this.refreshLive(false);
await this.loadApprovals();
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => this.refreshLive(false), 30000);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('dash-approvals-list');
const badge = document.getElementById('approvals-pending-badge');
if (!el) return;
const items = r.items || [];
if (badge) {
badge.style.display = items.length ? 'inline' : 'none';
badge.textContent = items.length + ' open';
}
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr class="'+(i.action_type==='maintenance_scan'?'approval-row-scan':'')+'"><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓ Goedkeuren</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table><p class="page-tip" style="margin-top:0.5rem"><a href="/agents?tab=approvals">Volledige queue →</a></p>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
},
async loadProjectsMini() {
try {
const r = await fetch('/api/projects?limit=5').then(x => x.json());
const el = document.getElementById('dash-projects-mini');
if (!el) return;
const items = r.items || [];
el.innerHTML = items.length ? items.map(p =>
'<a href="/projects?id='+p.id+'" class="beurs-mini-chip"><strong>'+p.name+'</strong> · '+(p.asset_count||0)+' assets</a>'
).join('') : '<p class="muted">Nog geen projecten — <a href="/projects">maak aan</a></p>';
} catch (e) {}
},
renderActivityLog(entries) {
const el = document.getElementById('briefing-activity-log');
if (!el) return;
el.innerHTML = (entries || []).length
? entries.slice(0, 12).map(e => '<li>' + e + '</li>').join('')
: '<li class="muted">Nog geen agent-acties vandaag — Herman documenteert automatisch.</li>';
},
renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini');
if (!el) return;
@@ -166,8 +270,10 @@ function dashboardBriefing() {
try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) {
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats);
this._stats = r.stats;
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats, this._vizMode);
this.renderClientsMini(r.stats);
this.renderActivityLog(r.stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
@@ -182,7 +288,7 @@ function dashboardBriefing() {
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
this.content = r.content || '';
this.createdAt = r.generated_at || new Date().toISOString();
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt);
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt, this._vizMode);
this.renderClientsMini(r.stats || {});
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
+113 -29
View File
@@ -2,12 +2,24 @@
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
{% 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=9" />
<style>
.doc-neo-shell { --hm-cyan: #00e5ff; --hm-border: rgba(0, 229, 255, 0.12); --hm-panel: #0d1219; }
.doc-neo-shell .page-header h1 { font-size: 1.55rem; }
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
.doc-chart-panel { height: 140px; }
.doc-chart-panel canvas { max-height: 110px !important; }
.doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
@media (max-width: 900px) { .doc-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
.doc-neo-kpi {
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.1rem 0.65rem;
}
.doc-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: #94a3b8; }
.doc-neo-kpi-value { font-size: 1.65rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0; }
.doc-chart-grid { display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:1rem; }
@media (max-width: 800px) { .doc-chart-grid { grid-template-columns: 1fr; } }
.doc-chart-panel .hm-chart-wrap { height: 130px; }
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
.photo-card { border:1px solid rgba(148,163,184,.15); border-radius:10px; overflow:hidden; background:rgba(0,0,0,.2); }
.photo-card { border:1px solid rgba(0,229,255,0.12); border-radius:12px; overflow:hidden; background:rgba(13,18,25,0.95); }
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
@@ -15,17 +27,33 @@
</style>
{% endblock %}
{% block content %}
<div x-data="documentsDash()" x-init="init()">
<header class="page-header">
<h1>Documents &amp; Foto's — interactief dashboard</h1>
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p>
<div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
<header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>Documents &amp; Foto's <span class="live-badge">Neo</span></h1>
<p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
</header>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi" @click="filterSentiment=''"><div class="kpi-label">Documenten</div><div class="kpi-value" x-text="filteredDocs.length"></div></div>
<div class="kpi-card purple clickable-kpi"><div class="kpi-label">Totaal woorden</div><div class="kpi-value">{{ summary.total_words }}</div></div>
<div class="kpi-card green clickable-kpi"><div class="kpi-label">Foto's in DB</div><div class="kpi-value" x-text="photos.length"></div></div>
<div class="kpi-card orange clickable-kpi"><div class="kpi-label">Gem. sentiment</div><div class="kpi-value">{{ summary.avg_sentiment }}</div></div>
<div class="doc-neo-kpi-row">
<div class="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
<div class="doc-neo-kpi-label">Documenten</div>
<div class="doc-neo-kpi-value" x-text="filteredDocs.length"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Totaal woorden</div>
<div class="doc-neo-kpi-value">{{ summary.total_words }}</div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Foto's in DB</div>
<div class="doc-neo-kpi-value" x-text="photos.length"></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Gem. sentiment</div>
<div class="doc-neo-kpi-value">{{ summary.avg_sentiment }}</div>
<div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
<div class="doc-dash-filters panel">
@@ -46,14 +74,18 @@
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
</div>
<div class="grid-2" style="margin-top:1rem">
<section class="panel doc-chart-panel">
<h2>Top woorden <small class="muted">klik = filter</small></h2>
<canvas id="wordsChart" height="110"></canvas>
<div class="doc-chart-grid">
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="wordsChart"></canvas></div>
<div id="viz-words-alt" class="viz-alt"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
<section class="panel doc-chart-panel">
<h2>Sentiment <small class="muted">klik = filter</small></h2>
<canvas id="sentimentChart" height="110"></canvas>
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="sentimentChart"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
</div>
@@ -132,8 +164,18 @@
</div>
</section>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem" x-show="activePhoto">
<select class="form-input" x-model="linkProjectId" style="max-width:200px">
<option value="">Koppel aan project…</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" @click="savePhotoToProject()">Opslaan in project</button>
<a href="/projects" class="btn btn-sm">Alle projecten →</a>
</div>
<div class="detection-wrap">
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
<canvas id="detectionCanvas"></canvas>
@@ -172,14 +214,28 @@ function documentsDash() {
activeOcr: '',
activeItems: [],
detections: [],
linkProjectId: '',
cockpitProjects: [],
liveRefresh: false,
liveTimer: null,
init() {
this.applyFilters();
this.renderCharts();
this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos();
this.refreshShare(false);
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
},
async savePhotoToProject() {
if (!this.activePhoto || !this.linkProjectId) return Cockpit.toast('Kies een project', 'error');
try {
await Cockpit.api('/api/projects/photos/' + this.activePhoto + '/link', {
method: 'POST',
body: JSON.stringify({ project_id: parseInt(this.linkProjectId, 10) }),
});
Cockpit.toast('Foto gekoppeld aan project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
mergeShareItems(items) {
@@ -235,16 +291,44 @@ function documentsDash() {
});
},
renderCharts() {
if (wordsChartInst) wordsChartInst.destroy();
if (sentimentChartInst) sentimentChartInst.destroy();
renderCharts(mode) {
mode = mode || window.__vizMode || 'neo-bars';
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
const dash = this;
const wordLabels = topWords.slice(0, 10).map(w => w.lemma);
const wordValues = topWords.slice(0, 10).map(w => w.total_count);
const sentLabels = ['Positief', 'Neutraal', 'Negatief'];
const sentValues = [summary.positive, summary.neutral, summary.negative];
if (window.VizEngine && (mode === 'neo-equalizer' || mode === 'neo-cards' || mode === 'neo-table')) {
VizEngine.renderWidget(mode, {
canvas: document.getElementById('wordsChart'),
altContainer: document.getElementById('viz-words-alt'),
key: 'doc-words',
labels: wordLabels, values: wordValues,
rows: wordLabels.map((l, i) => ({ label: l, value: wordValues[i], raw: wordValues[i] })),
eqClass: 'hm-eq-cyan',
});
VizEngine.renderWidget(mode, {
canvas: document.getElementById('sentimentChart'),
altContainer: document.getElementById('viz-sentiment-alt'),
key: 'doc-sentiment',
labels: sentLabels, values: sentValues,
rows: sentLabels.map((l, i) => ({ label: l, value: sentValues[i], raw: sentValues[i] })),
eqClass: 'hm-eq-purple',
});
return;
}
document.getElementById('viz-words-alt').innerHTML = '';
document.getElementById('viz-sentiment-alt').innerHTML = '';
document.getElementById('wordsChart').style.display = '';
document.getElementById('sentimentChart').style.display = '';
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
type: 'bar',
type: mode === 'neo-rings' ? 'polarArea' : 'bar',
data: {
labels: topWords.slice(0, 10).map(w => w.lemma),
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
backgroundColor: 'rgba(232,168,56,.75)', borderWidth: 0 }]
backgroundColor: 'rgba(0,229,255,0.75)', borderWidth: 0, borderRadius: 6 }]
},
options: {
responsive: true, maintainAspectRatio: false,
@@ -255,11 +339,11 @@ function documentsDash() {
}
});
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
type: 'doughnut',
type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }]
backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false,
+13 -2
View File
@@ -3,9 +3,15 @@
<div x-data="hermanChat()" class="chat-layout">
<header class="page-header">
<h1>Herman</h1>
<p class="subtitle page-tip">Co-CEO orchestrator — routes your message to specialist agents.</p>
<p class="subtitle page-tip">Co-CEO orchestrator — delegeert naar specialist agents (packaging, design, …).</p>
</header>
<div class="card card-compact" style="margin-bottom:1rem;">
<h2 style="font-size:1rem;margin:0 0 .5rem;">Verpakking via Packaging agent</h2>
<p class="page-tip" style="margin:0 0 .5rem;">Start met <code>maak verpakking:</code> — de agent genereert het design, slaat op op NAS en rapporteert aan Herman.</p>
<p class="page-tip" style="margin:0;font-size:0.78rem">Voorbeeld: <em>maak verpakking: pouch 100×140 voor Halal Kip Curry project #5 halal-badge</em></p>
</div>
<div class="card card-compact" style="margin-bottom:1rem;">
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2>
<p class="page-tip" style="margin:0 0 .75rem;">Beschrijf wat je wilt zien. Op CPU duurt dit 13 minuten.</p>
@@ -30,7 +36,7 @@
{% endfor %}
</div>
<form class="chat-input" @submit.prevent="send">
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak foto: product op witte achtergrond)" autocomplete="off" />
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak verpakking: doos 120×80×40 voor Chai N Masala project #3)" autocomplete="off" />
<button class="btn btn-primary" type="submit" :disabled="loading">Send</button>
</form>
</div>
@@ -62,6 +68,11 @@ function hermanChat() {
html += '<img src="' + r.image_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />';
this.lastImage = r.image_url;
}
if (r.packaging_studio_url) {
html += '<p style="margin-top:.5rem"><a class="btn btn-sm" href="' + esc(r.packaging_studio_url) + '">📦 Open Packaging Studio</a>';
if (r.packaging_pdf_url) html += ' <a class="btn btn-sm btn-ghost" href="' + esc(r.packaging_pdf_url) + '" target="_blank">PDF</a>';
html += '</p>';
}
html += '</div>';
log.innerHTML += html;
this.message = '';
+75 -1
View File
@@ -1,4 +1,8 @@
{% extends "base.html" %}
{% 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" />
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
<header class="page-header">
@@ -9,9 +13,29 @@
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/projects">Projecten →</a>
</div>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Marketing data</h4><small>RSS · trends · kansen</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing"></canvas></div>
<div id="viz-marketing-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Publish jobs</h4><small>status verdeling</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing-publish"></canvas></div>
<div id="viz-marketing-publish-alt" class="viz-alt"></div>
</div>
</div>
</div>
<div class="live-feed-bar" x-show="dashboard.rss?.length">
<div class="ticker-track">
<template x-for="r in dashboard.rss" :key="r.id">
@@ -191,6 +215,12 @@
<textarea class="form-input" rows="5" x-model="publishForm.text" placeholder="Post tekst — UTF-8 emoji ondersteund 🥘✨" style="width:100%;margin-bottom:0.75rem"></textarea>
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
<select class="form-input" x-model="publishForm.project_id" style="max-width:220px">
<option value="">Opslaan in agent-project (auto)</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<label class="btn btn-secondary btn-sm">
📷 Foto uploaden
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
@@ -359,6 +389,8 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script>
function marketingHub() {
return {
@@ -369,8 +401,9 @@ function marketingHub() {
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [] },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
cockpitProjects: [],
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨‍🍳','💚','📁','🎯'],
promoForm: { chain: '', title: '', folder_path: '', description: '' },
_pollStop: null,
@@ -385,11 +418,16 @@ function marketingHub() {
async init() {
await this.loadDashboard();
await this.loadBookmarks();
await this.loadPublishHistory();
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
if (this.tab === 'regelgeving') await this.loadRegulations();
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
this.$nextTick(() => this.updateTabIndicator());
if (window.PageViz) {
window.PageViz.init('marketing', (mode) => this.renderViz(mode));
}
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
const el=document.getElementById('reco-list');
if(!el) return;
@@ -510,6 +548,7 @@ function marketingHub() {
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
},
@@ -540,8 +579,42 @@ function marketingHub() {
try {
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
this.publishHistory = r.items || [];
if (window.__marketingRenderViz) window.__marketingRenderViz(window.__vizMode || 'neo-bars');
} catch (e) { this.publishHistory = []; }
},
renderViz(mode) {
window.__vizMode = mode;
window.__marketingRenderViz = (m) => this.renderViz(m);
if (!window.VizEngine) return;
const d = this.dashboard || {};
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing'),
document.getElementById('viz-marketing-alt'),
'marketing-main', 'Marketing KPIs',
[
{ label: 'RSS', value: (d.rss || []).length, raw: (d.rss || []).length },
{ label: 'Trends', value: (d.trends || []).length, raw: (d.trends || []).length },
{ label: 'Kansen', value: (d.top_opportunities || []).length, raw: (d.top_opportunities || []).length },
{ label: 'Promo', value: this.promos.length, raw: this.promos.length },
],
'hm-eq-purple', VizEngine.MODES ? null : null
);
const statusMap = {};
(this.publishHistory || []).forEach(j => {
const s = j.status || 'unknown';
statusMap[s] = (statusMap[s] || 0) + 1;
});
const labels = Object.keys(statusMap);
const values = labels.map(k => statusMap[k]);
if (!labels.length) { labels.push('geen'); values.push(0); }
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing-publish'),
document.getElementById('viz-marketing-publish-alt'),
'marketing-publish', 'Publish status',
labels.map((l, i) => ({ label: l, value: values[i], raw: values[i] })),
'hm-eq-cyan'
);
},
publishResultSummary(j) {
const res = j.result || {};
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } }
@@ -573,6 +646,7 @@ function marketingHub() {
image_url: this.publishForm.image_url || null,
media_ids: this.publishForm.media_ids || [],
channels: this.publishForm.channels,
project_id: this.publishForm.project_id ? parseInt(this.publishForm.project_id, 10) : null,
};
const r = await fetch('/api/marketing/publish', {
method: 'POST',
+190 -89
View File
@@ -1,131 +1,232 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
<header class="ops-header">
<div class="ops-shell hm-neo" x-data="opsPage()" x-init="init()">
<header class="ops-header herman-hero">
<div>
<h1>IT Ops Infrastructure</h1>
<p class="ops-subtitle">Realtime Proxmox + service health topology</p>
<h1>IT Ops · Dell R340 <span class="live-badge">Live</span></h1>
<p class="hero-sub">PowerEdge R340 → Proxmox VE → VM 106 → online services · SysOps agent</p>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">Topology</button>
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</button>
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">Verversen</button>
<button class="btn btn-sm btn-pulse-green" @click="snapshot()" :disabled="loading">Snapshot</button>
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-purple">Goedkeuringen →</a>
</div>
</header>
<section class="ops-kpis">
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div>
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div>
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div>
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></div>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Maintenance severity</h4><small>open items</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-maint"></canvas></div>
<div id="viz-ops-maint-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Backup status</h4><small>Gitea commits</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-backup"></canvas></div>
<div id="viz-ops-backup-alt" class="viz-alt"></div>
</div>
</div>
</div>
<section class="ops-kpis hm-neo-kpi-row" style="grid-template-columns:repeat(5,1fr)">
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Health</div><div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Online nodes</div><div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? 0"></div><div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Services</div><div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div><div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Open maintenance</div><div class="hm-neo-kpi-value" x-text="maintenance.length"></div><div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Laatste backup</div><div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="lastBackupLabel()"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</section>
<div class="ops-panels-grid">
<section class="panel ops-side-panel">
<h2>Maintenance · SysOps</h2>
<p class="page-tip">Dagelijks scan — updates, schijf, Docker health</p>
<template x-if="!maintenance.length">
<p class="empty-state">Geen open items 🎉</p>
</template>
<div class="ops-maint-list">
<template x-for="n in maintenance" :key="n.id">
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
<strong x-text="n.title"></strong>
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="n.body" x-show="n.body"></p>
</div>
</template>
</div>
</section>
<section class="panel ops-side-panel">
<h2>Config backups · Gitea</h2>
<p class="page-tip">SysOps vraagt dagelijks goedkeuring — na ✓ push naar Gitea</p>
<template x-if="pendingBackup">
<div class="ops-maint-item sev-warning">
<strong>⏳ Wacht op goedkeuring</strong>
<p x-text="pendingBackup.title"></p>
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-green">Keur goed →</a>
</div>
</template>
<div class="ops-maint-list">
<template x-for="b in backups.slice(0,5)" :key="b.id">
<div class="ops-maint-item" :class="b.status==='success'?'sev-info':'sev-warning'">
<strong x-text="b.status + (b.commit_ref ? ' · '+b.commit_ref : '')"></strong>
<small x-text="(b.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="b.message"></p>
</div>
</template>
</div>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="btn btn-sm" style="margin-top:0.5rem">Gitea repo →</a>
</section>
<section class="panel ops-side-panel" style="grid-column:1/-1">
<h2>SysOps verslag · activiteitenlog</h2>
<p class="page-tip">Elke deploy, Gitea-push en maintenance-scan — Herman leest dit in de dagelijkse briefing.</p>
<div class="ops-maint-list">
<template x-for="a in sysopsActivity" :key="a.id">
<div class="ops-maint-item" :class="a.status==='success'?'sev-info':'sev-warning'">
<strong x-text="a.title"></strong>
<small x-text="(a.action_type||'')+' · '+(a.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="a.body" x-show="a.body"></p>
<small x-show="a.commit_ref" style="color:#38bdf8" x-text="'commit '+a.commit_ref"></small>
</div>
</template>
</div>
<p x-show="!sysopsActivity.length" class="empty-state">Nog geen SysOps-activiteit gelogd.</p>
</section>
</div>
<section class="panel topology-card">
<svg viewBox="0 0 1200 600" class="topology-canvas">
<g class="topology-lines">
<template x-for="edge in edges" :key="edge.id">
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
</template>
</g>
<g>
<template x-for="edge in edges" :key="'pulse-'+edge.id">
<line class="pulse-line" :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
</template>
</g>
<template x-for="node in nodes" :key="node.id">
<g class="ops-node" :class="node.css" :transform="'translate('+node.x+','+node.y+')'">
<circle r="36"></circle>
<text text-anchor="middle" y="-2" x-text="node.label"></text>
<text class="sub" text-anchor="middle" y="16" x-text="node.status"></text>
</g>
</template>
</svg>
<div class="ops-legend">
<span><i class="dot dot-online"></i> Online / bereikbaar</span>
<span><i class="dot dot-degraded"></i> Onbekend</span>
<span><i class="dot dot-offline"></i> Offline</span>
<span>Klik service-node → opent URL</span>
</div>
<svg id="ops-topology-canvas" viewBox="0 0 1200 620" class="topology-canvas"></svg>
</section>
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
<p class="ops-meta">Last update: <span id="ops-meta-time"></span></p>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/ops-topology.js?v=3"></script>
<script>
function opsTopology() {
function opsPage() {
return {
loading: false,
summary: { counts: {} },
nodes: [],
edges: [],
lastUpdate: null,
meta: {},
maintenance: [],
backups: [],
sysopsActivity: [],
pendingBackup: null,
timer: null,
positions: {
'proxmox-host': { x: 600, y: 90 },
'vm105-hermes': { x: 340, y: 230 },
'vm106-command': { x: 860, y: 230 },
'svc-gitea': { x: 190, y: 390 },
'svc-ollama': { x: 480, y: 390 },
'svc-cockpit': { x: 700, y: 390 },
'svc-tools-api': { x: 860, y: 390 },
'svc-email-agent': { x: 1020, y: 390 }
renderViz(mode) {
if (!window.VizEngine) return;
const sevMap = {};
(this.maintenance || []).forEach(n => {
const s = n.severity || 'info';
sevMap[s] = (sevMap[s] || 0) + 1;
});
let sevRows = Object.keys(sevMap).map(k => ({ label: k, value: sevMap[k], raw: sevMap[k] }));
if (!sevRows.length) sevRows = [{ label: 'ok', value: 0, raw: 0 }];
VizEngine.renderFromRows(mode,
document.getElementById('chart-ops-maint'),
document.getElementById('viz-ops-maint-alt'),
'ops-maint', 'Maintenance', sevRows, 'hm-eq-purple'
);
const stMap = {};
(this.backups || []).forEach(b => {
const s = b.status || 'unknown';
stMap[s] = (stMap[s] || 0) + 1;
});
let stRows = Object.keys(stMap).map(k => ({ label: k, value: stMap[k], raw: stMap[k] }));
if (!stRows.length) stRows = [{ label: 'geen', value: 0, raw: 0 }];
VizEngine.renderFromRows(mode,
document.getElementById('chart-ops-backup'),
document.getElementById('viz-ops-backup-alt'),
'ops-backup', 'Backups', stRows, 'hm-eq-cyan'
);
},
init() {
this.loadStatus();
this.loadTopology();
this.timer = setInterval(() => this.loadTopology(), 30000);
async init() {
await this.loadAll();
await this.draw();
if (window.PageViz) {
window.PageViz.init('ops', (mode) => this.renderViz(mode));
}
this.timer = setInterval(() => this.refresh(false), 30000);
},
lastBackupLabel() {
const ok = this.backups.find(b => b.status === 'success');
if (!ok) return '—';
return (ok.commit_ref || ok.status) + ' · ' + (ok.created_at || '').substring(0, 10);
},
async loadAll() {
await Promise.all([this.loadStatus(), this.loadMaintenance(), this.loadBackups(), this.loadSysopsActivity(), this.loadPendingApproval()]);
this.renderViz(window.__vizMode || 'neo-bars');
},
async loadStatus() {
try {
const data = await fetch('/api/ops/status').then(r => r.json());
this.summary = data || { counts: {} };
this.lastUpdate = data.generated_at || new Date().toISOString();
} catch (e) {
console.warn(e);
this.meta = (data.topology && data.topology.meta) || data.sources || {};
} catch (e) { console.warn(e); }
},
async loadMaintenance() {
try {
const r = await fetch('/api/ops/maintenance?limit=12').then(x => x.json());
this.maintenance = (r.items || []).filter(n => !n.resolved);
} catch (e) { this.maintenance = []; }
},
async loadBackups() {
try {
const r = await fetch('/api/ops/backups?limit=8').then(x => x.json());
this.backups = r.items || [];
} catch (e) { this.backups = []; }
},
async loadSysopsActivity() {
try {
const r = await fetch('/api/ops/sysops-activity?limit=20').then(x => x.json());
this.sysopsActivity = r.items || [];
} catch (e) { this.sysopsActivity = []; }
},
async loadPendingApproval() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=20').then(x => x.json());
this.pendingBackup = (r.items || []).find(i => i.agent_key === 'sysops' && i.action_type === 'config_backup') || null;
} catch (e) { this.pendingBackup = null; }
},
async draw() {
if (window.OpsTopology) {
await window.OpsTopology.render('ops-topology-canvas', document.getElementById('ops-meta-time'));
}
},
async loadTopology() {
async refresh(toast) {
this.loading = true;
try {
const data = await fetch('/api/ops/topology').then(r => r.json());
this.toGraph(data);
await this.loadStatus();
} catch (e) {
console.warn(e);
} finally {
this.loading = false;
}
await this.loadAll();
await this.draw();
if (toast !== false && window.Cockpit) Cockpit.toast('IT Ops bijgewerkt', 'success');
} finally { this.loading = false; }
},
async refreshInfra() {
async snapshot() {
this.loading = true;
try {
await fetch('/api/ops/refresh', { method: 'POST' });
await this.loadTopology();
await this.refresh(false);
if (window.Cockpit) Cockpit.toast('Infra snapshot opgeslagen', 'success');
} catch (e) {
console.warn(e);
} finally {
this.loading = false;
}
if (window.Cockpit) Cockpit.toast('Snapshot mislukt', 'error');
} finally { this.loading = false; }
},
toGraph(payload) {
const outNodes = [];
const outEdges = [];
const flatten = (n, parentId = null) => {
const pos = this.positions[n.id] || { x: 80 + outNodes.length * 120, y: 520 };
const status = (n.status || 'unknown').toLowerCase();
const kind = (n.type || 'service').toLowerCase();
const css = `node-${kind} status-${status}`;
outNodes.push({ id: n.id, label: n.label, status: n.status || 'unknown', x: pos.x, y: pos.y, css });
if (parentId) {
const p = this.positions[parentId];
if (p) {
outEdges.push({ id: `${parentId}-${n.id}`, x1: p.x, y1: p.y + 36, x2: pos.x, y2: pos.y - 36 });
}
}
(n.children || []).forEach(child => flatten(child, n.id));
};
(payload.nodes || []).forEach(root => flatten(root, null));
this.nodes = outNodes;
this.edges = outEdges;
this.lastUpdate = payload.generated_at || new Date().toISOString();
}
};
}
</script>
+333 -105
View File
@@ -1,92 +1,173 @@
{% extends "base.html" %}
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
{% block extra_head %}
<style>
.pack-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
@media (max-width: 1024px) { .pack-grid { grid-template-columns: 1fr; } }
.pack-preview {
min-height: 460px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(0,229,255,.08), rgba(0,0,0,.35));
border: 1px solid rgba(0,229,255,.2);
border-radius: 12px;
overflow: auto;
padding: 1rem;
}
.pack-preview svg { width: 100%; max-height: 72vh; }
.pack-tools { display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.75rem; }
.elements-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; }
.project-list { max-height: 260px; overflow: auto; display:flex; flex-direction:column; gap:.5rem; }
.project-item { border:1px solid rgba(148,163,184,.15); border-radius:10px; padding:.6rem; background:rgba(0,0,0,.25); }
.project-item small { color: var(--text-secondary); display:block; margin-top:.25rem; }
</style>
<link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="packagingStudio()" x-init="init()">
<header class="page-header">
<h1>Packaging Studio</h1>
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p>
<div class="pack-studio" x-data="packagingStudio()" x-init="init()">
<header class="pack-hero">
<div>
<h1>📦 Packaging Studio</h1>
<p>Ontwerp verpakkingen met Python SVG-engine · koppel aan project · export naar NAS · email &amp; kopieer naar ander project.</p>
</div>
<div class="pack-toolbar">
<button class="btn btn-sm" @click="preview()" :disabled="loading">👁 Preview</button>
<button class="btn btn-primary btn-sm" @click="save()" :disabled="loading">
<span x-text="packagingId ? '💾 Bijwerken' : '💾 Opslaan'"></span>
</button>
<button class="btn btn-sm" @click="duplicate()" x-show="packagingId" :disabled="loading">📋 Dupliceer</button>
<button class="btn btn-sm" @click="showCopyModal=true" x-show="packagingId">📁 Kopieer naar project</button>
<button class="btn btn-sm" @click="showEmailModal=true" x-show="packagingId">✉ Email</button>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('svg')" x-show="packagingId">SVG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('png')" x-show="packagingId">PNG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('pdf')" x-show="packagingId">PDF</a>
<button class="btn btn-sm btn-ghost" @click="newDesign()"> Nieuw</button>
</div>
</header>
<div class="pack-grid">
<section class="panel">
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;">
<label>Type
<select class="form-input" x-model="form.type">
<option value="folding_box">Folding box</option>
<option value="wrap">Wrap</option>
<option value="round_label">Round label</option>
</select>
</label>
<label>Barcode value
<input class="form-input" x-model="form.barcode_value" placeholder="8710000000012" />
</label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" /></label>
<div class="pack-layout">
<aside class="pack-panel">
<div class="pack-tabs">
<button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
<button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
<button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
<button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
<button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
</div>
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2>
<div class="elements-grid">
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label>
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label>
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label>
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</label>
<div x-show="sideTab==='shape'">
<h3>Afmetingen &amp; type</h3>
<div class="pack-field-grid">
<label class="full">Design naam
<input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
<label class="full">Type
<select class="form-input" x-model="form.type">
<template x-for="t in types" :key="t.id">
<option :value="t.id" x-text="t.label"></option>
</template>
</select></label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" @input="debouncedPreview()" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" @input="debouncedPreview()" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" @input="debouncedPreview()" /></label>
<label>Bleed (mm)<input type="number" class="form-input" x-model.number="form.bleed_mm" min="0" max="20" @input="debouncedPreview()" /></label>
<label class="full">Barcode<input class="form-input" x-model="form.barcode_value" @input="debouncedPreview()" /></label>
<label class="full">QR URL<input class="form-input" x-model="form.qr_value" placeholder="https://foodlinkk.nl/product/..." @input="debouncedPreview()" /></label>
</div>
</div>
<div class="pack-tools">
<button class="btn btn-primary" @click="generate()" :disabled="loading">
<span x-show="!loading">Genereer verpakking</span>
<span x-show="loading">Genereren…</span>
</button>
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a>
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a>
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a>
<div x-show="sideTab==='brand'" x-cloak>
<h3>Kleuren &amp; stijl</h3>
<div class="pack-field-grid">
<template x-for="c in colorFields" :key="c.key">
<label class="full" x-text="c.label">
<div class="pack-color-row">
<input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
<input type="color" :value="form.brand[c.key]" @input="form.brand[c.key]=$event.target.value; debouncedPreview()" />
</div>
</label>
</template>
</div>
</div>
<div x-show="sideTab==='text'" x-cloak>
<h3>Producttekst</h3>
<div class="pack-field-grid">
<label class="full">Productnaam<input class="form-input" x-model="form.text.product_name" @input="debouncedPreview()" /></label>
<label class="full">Tagline<input class="form-input" x-model="form.text.tagline" @input="debouncedPreview()" /></label>
<label>Netto gewicht<input class="form-input" x-model="form.text.subtitle" placeholder="400g" @input="debouncedPreview()" /></label>
<label>Herkomst<input class="form-input" x-model="form.text.origin" @input="debouncedPreview()" /></label>
<label class="full">Ingrediënten<textarea class="form-input" rows="3" x-model="form.text.ingredients" @input="debouncedPreview()"></textarea></label>
<label class="full">THT / best before<input class="form-input" x-model="form.text.best_before" @input="debouncedPreview()" /></label>
</div>
</div>
<div x-show="sideTab==='elements'" x-cloak>
<h3>Print &amp; productie-elementen</h3>
<div class="pack-elements">
<template x-for="el in elementList" :key="el.key">
<label><input type="checkbox" x-model="form.elements[el.key]" @change="debouncedPreview()"> <span x-text="el.label"></span></label>
</template>
</div>
</div>
<div x-show="sideTab==='project'" x-cloak>
<h3>Project &amp; NAS</h3>
<div class="pack-field-grid">
<label class="full">Bestaand project
<select class="form-input" x-model="save.project_id">
<option value="">— Nieuw project —</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<label class="full" x-show="!save.project_id">Nieuw projectnaam
<input class="form-input" x-model="save.project_name" placeholder="Jumbo pouch Q3" /></label>
<label class="full" x-show="!save.project_id">Klant
<select class="form-input" x-model="save.client_id">
<option value=""></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
</div>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">Bij opslaan: SVG, PNG, PDF + manifest → <code>…/packaging/</code> op NAS.</p>
<div x-show="nasInfo.ok" class="pack-nas-badge" style="margin-top:0.5rem">✓ NAS export OK</div>
<div class="muted" style="font-size:0.68rem;margin-top:0.35rem;word-break:break-all" x-show="nasInfo.files?.svg" x-text="nasInfo.files?.svg"></div>
</div>
</aside>
<section class="pack-panel pack-preview-wrap">
<h3>Live preview</h3>
<div class="pack-preview-stage">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Klik Preview of pas instellingen aan.</p>
<p class="page-tip" x-show="loading">Genereren…</p>
</div>
<div class="pack-meta-bar">
<span x-text="packagingId ? 'ID: '+packagingId.substring(0,8)+'…' : 'Nog niet opgeslagen'"></span>
<span x-text="form.type + ' · ' + form.width_mm + '×' + form.height_mm + '×' + form.depth_mm + ' mm'"></span>
</div>
</section>
<aside class="panel">
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2>
<div class="project-list">
<template x-for="p in projects" :key="p.id">
<div class="project-item">
<strong x-text="p.spec.type"></strong>
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small>
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small>
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button>
</div>
<aside class="pack-panel">
<h3>Bibliotheek</h3>
<div class="pack-library">
<template x-for="p in library" :key="p.id">
<article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
<strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
<small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
<small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
</article>
</template>
<p class="page-tip" x-show="!projects.length">Nog geen packaging projecten.</p>
<p class="page-tip" x-show="!library.length">Nog geen designs opgeslagen.</p>
</div>
</aside>
</div>
<div class="panel" style="margin-top:1rem;">
<div class="pack-preview">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p>
<div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
<div class="pack-modal" x-show="showEmailModal" x-cloak>
<h3>✉ Design emailen</h3>
<label>Aan (komma-gescheiden)<input class="form-input" x-model="emailForm.to" placeholder="klant@example.com" /></label>
<label style="margin-top:0.5rem;display:block">Onderwerp<input class="form-input" x-model="emailForm.subject" /></label>
<label style="margin-top:0.5rem;display:block">Bericht<textarea class="form-input" rows="4" x-model="emailForm.body"></textarea></label>
<p class="page-tip" style="font-size:0.7rem;margin-top:0.5rem">Downloadlinks voor SVG, PNG en PDF worden automatisch toegevoegd.</p>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showEmailModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="sendEmail()">Verstuur</button>
</div>
</div>
<div class="pack-modal-backdrop" x-show="showCopyModal" x-cloak @click="showCopyModal=false"></div>
<div class="pack-modal" x-show="showCopyModal" x-cloak>
<h3>📁 Kopieer naar ander project</h3>
<label>Doelproject
<select class="form-input" x-model="copyTargetId">
<option value="">— Selecteer —</option>
<template x-for="p in cockpitProjects" :key="'cp'+p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showCopyModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="copyToProject()">Kopieer</button>
</div>
</div>
</div>
@@ -94,58 +175,205 @@
{% block scripts %}
<script>
function packagingStudio() {
const DEFAULT_BRAND = {
bg: '#0b1220', panel: '#101a2d', primary: '#00e5ff', secondary: '#ffd700',
accent: '#b8ff3c', text: '#e2e8f0', muted: '#94a3b8', cut_line: '#ef4444',
fold_line: '#60a5fa', bleed: '#f97316', halal: '#22c55e'
};
const DEFAULT_ELEMENTS = {
barcode: true, logo_area: true, fold_lines: true, cut_lines: true,
nutrition_panel: false, ingredients: true, halal_badge: false, window: false,
qr_code: false, glue_tabs: false, bleed: true, dimensions: true
};
return {
loading: false,
sideTab: 'shape',
svg: '',
projectId: '',
projects: [],
packagingId: '',
library: [],
cockpitProjects: [],
clients: [],
types: [],
nasInfo: {},
showEmailModal: false,
showCopyModal: false,
copyTargetId: '',
previewTimer: null,
save: { project_name: '', client_id: '', project_id: '{{ initial_project_id or "" }}' },
emailForm: { to: '', subject: 'Foodlinkk packaging design', body: 'Bijgevoegd vind je ons packaging design.' },
colorFields: [
{ key: 'primary', label: 'Primair' }, { key: 'secondary', label: 'Secundair' },
{ key: 'accent', label: 'Accent' }, { key: 'panel', label: 'Paneel' },
{ key: 'text', label: 'Tekst' }, { key: 'bg', label: 'Achtergrond' }
],
elementList: [
{ key: 'logo_area', label: 'Logo zone' }, { key: 'barcode', label: 'Barcode' },
{ key: 'qr_code', label: 'QR code' }, { key: 'nutrition_panel', label: 'Voedingswaarden' },
{ key: 'ingredients', label: 'Ingrediënten' }, { key: 'halal_badge', label: 'Halal badge' },
{ key: 'window', label: 'Venster' }, { key: 'fold_lines', label: 'Vouwlijnen' },
{ key: 'cut_lines', label: 'Snijlijnen' }, { key: 'glue_tabs', label: 'Lijmtabs' },
{ key: 'bleed', label: 'Bleed marge' }, { key: 'dimensions', label: 'Afmetingen' }
],
form: {
type: 'folding_box',
width_mm: 120,
height_mm: 80,
depth_mm: 40,
barcode_value: '8710000000012',
brand: {},
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true }
width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
barcode_value: '8710000000012', qr_value: '',
design_name: 'Nieuw design',
brand: { ...DEFAULT_BRAND },
text: {
product_name: 'FOODLINKK Premium',
tagline: 'Authentiek · Vers · Halal',
subtitle: '400g', origin: 'Geproduceerd in NL',
ingredients: 'Kip 45%, water, specerijen, zout, ui, knoflook.',
best_before: 'Ten minste houdbaar tot: zie verpakking'
},
elements: { ...DEFAULT_ELEMENTS }
},
async init() { await this.refreshProjects(); },
downloadUrl(format) {
if (!this.projectId) return '#';
return '/api/packaging/download/' + this.projectId + '?format=' + format;
},
async refreshProjects() {
async init() {
await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
try {
const r = await Cockpit.api('/api/packaging/projects?limit=20');
this.projects = r.items || [];
const r = await Cockpit.api('/clients');
this.clients = r.clients || r.items || [];
} catch (e) { this.clients = []; }
const pid = new URLSearchParams(location.search).get('project_id') || this.save.project_id;
if (pid) this.save.project_id = String(pid);
await this.preview();
},
async loadTypes() {
try {
const r = await fetch('/api/packaging/types').then(x => x.json());
this.types = r.items || [];
} catch (e) {
this.projects = [];
this.types = [
{ id: 'folding_box', label: 'Folding box' }, { id: 'wrap', label: 'Wrap' },
{ id: 'round_label', label: 'Rond label' }, { id: 'sleeve', label: 'Sleeve' },
{ id: 'pouch', label: 'Pouch' }, { id: 'tray', label: 'Tray' }
];
}
},
async loadProject(id) {
this.projectId = id;
try {
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text());
this.svg = data;
} catch (e) {
Cockpit.toast('Kon project niet laden', 'error');
payload() {
const body = JSON.parse(JSON.stringify(this.form));
if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
else if (this.save.project_name) {
body.project_name = this.save.project_name;
if (this.save.client_id) body.client_id = parseInt(this.save.client_id, 10);
}
return body;
},
async generate() {
debouncedPreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.preview(), 450);
},
async preview() {
if (this.loading) return;
this.loading = true;
try {
const r = await Cockpit.api('/api/packaging/generate', {
method: 'POST',
body: JSON.stringify(this.form)
});
const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
this.svg = r.svg || '';
this.projectId = r.id || '';
await this.refreshProjects();
Cockpit.toast('Packaging gegenereerd', 'success');
} catch (e) {
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
}
} catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
this.loading = false;
},
async save() {
if (this.loading) return;
this.loading = true;
try {
const url = this.packagingId ? '/api/packaging/' + this.packagingId : '/api/packaging/generate';
const method = this.packagingId ? 'PATCH' : 'POST';
const r = await Cockpit.api(url, { method, body: JSON.stringify(this.payload()) });
this.svg = r.svg || this.svg;
this.packagingId = r.id || this.packagingId;
this.nasInfo = r.nas || {};
await this.refreshLibrary();
await this.loadCockpitProjects();
Cockpit.toast(this.packagingId ? 'Design bijgewerkt' : 'Design opgeslagen', 'success');
} catch (e) { Cockpit.toast(e.message || 'Opslaan mislukt', 'error'); }
this.loading = false;
},
async duplicate() {
if (!this.packagingId) return;
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/duplicate', { method: 'POST', body: '{}' });
await this.loadDesign(r.id);
Cockpit.toast('Design gedupliceerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async copyToProject() {
if (!this.packagingId || !this.copyTargetId) return Cockpit.toast('Selecteer doelproject', 'warn');
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/copy-to-project', {
method: 'POST',
body: JSON.stringify({ target_project_id: parseInt(this.copyTargetId, 10) })
});
this.showCopyModal = false;
await this.loadDesign(r.id);
Cockpit.toast('Gekopieerd naar project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async sendEmail() {
if (!this.packagingId) return;
const to = (this.emailForm.to || '').split(',').map(s => s.trim()).filter(Boolean);
if (!to.length) return Cockpit.toast('Vul ontvanger in', 'warn');
try {
await Cockpit.api('/api/packaging/' + this.packagingId + '/email', {
method: 'POST',
body: JSON.stringify({ ...this.emailForm, to })
});
this.showEmailModal = false;
Cockpit.toast('Email verstuurd', 'success');
} catch (e) { Cockpit.toast(e.message || 'Email mislukt — check Settings → Email', 'error'); }
},
newDesign() {
this.packagingId = '';
this.nasInfo = {};
this.form.design_name = 'Nieuw design';
this.preview();
},
downloadUrl(format) {
return this.packagingId ? '/api/packaging/download/' + this.packagingId + '?format=' + format : '#';
},
async refreshLibrary() {
try {
const r = await Cockpit.api('/api/packaging/projects?limit=40');
this.library = r.items || [];
} catch (e) { this.library = []; }
},
async loadCockpitProjects() {
try {
const r = await fetch('/api/projects?limit=80').then(x => x.json());
this.cockpitProjects = r.items || [];
} catch (e) { this.cockpitProjects = []; }
},
async loadDesign(id) {
try {
const r = await Cockpit.api('/api/packaging/' + id);
const item = r.item || {};
const spec = item.spec || {};
this.packagingId = item.id;
this.svg = item.svg || '';
if (spec.type) this.form.type = spec.type;
['width_mm','height_mm','depth_mm','bleed_mm','barcode_value','qr_value','design_name'].forEach(k => {
if (spec[k] !== undefined) this.form[k] = spec[k];
});
this.form.brand = { ...DEFAULT_BRAND, ...(spec.brand || {}) };
this.form.text = { ...this.form.text, ...(spec.text || {}) };
this.form.elements = { ...DEFAULT_ELEMENTS, ...(spec.elements || {}) };
if (item.cockpit_project_id) this.save.project_id = String(item.cockpit_project_id);
Cockpit.toast('Design geladen', 'info');
} catch (e) { Cockpit.toast('Laden mislukt', 'error'); }
}
};
}
+247 -27
View File
@@ -1,40 +1,260 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="productsCrud()">
<header class="page-header"><h1>Products</h1><p class="subtitle page-tip">SKU catalog with margin calculator.</p></header>
<button class="btn btn-primary" @click="show=true; id=null">Add product</button>
<section class="panel"><h2>Margin calculator</h2>
<div class="form-grid"><input type="number" class="form-input" x-model="margin.cost" placeholder="Cost" />
<input type="number" class="form-input" x-model="margin.sell" placeholder="Sell" />
<button class="btn btn-secondary" @click="calcMargin()">Calculate</button></div>
<p x-show="margin.result">Margin: <strong x-text="margin.result"></strong>%</p></section>
<table class="data-table"><thead><tr><th>Name</th><th>Status</th><th>Margin %</th><th></th></tr></thead>
<tbody>{% for p in products %}<tr class="clickable-row" @click="edit({{ p.id }}, '{{ p.name|e }}', '{{ p.status|e }}', {{ p.margin_pct or 0 }})">
<td>{{ p.name }}</td><td>{{ p.status }}</td><td>{{ p.margin_pct }}</td>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ p.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.status" /><input type="number" class="form-input" x-model="form.margin_pct" />
<button class="btn btn-primary" @click="save()">Save</button></div></div>
<div class="clients-shell" x-data="productsHub()" x-init="init()" @foodlinkk:locale.window="onLocale()">
<header class="page-header">
<div>
<h1 x-text="$t('products.title')">Productcatalogus</h1>
<p class="subtitle page-tip" x-text="$t('products.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<a class="btn btn-sm" href="/clients" x-text="$t('nav.clients')"></a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span x-text="$t('products.total')"></span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.active')"></span><strong x-text="stats.active||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.avg_margin')"></span><strong x-text="(stats.avg_margin||0)+'%'"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.with_client')"></span><strong x-text="stats.with_client||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('products.tab_grid')"></button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('products.tab_list')"></button>
<button class="clients-tab" :class="{active: tab==='margin'}" @click="tab='margin'" x-text="$t('products.tab_margin')"></button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('products.new')"></button>
</div>
</div>
<div x-show="tab==='grid'" x-cloak>
<div class="agents-neo-grid">
<template x-for="p in filtered" :key="p.id">
<article class="agent-soul-card" @click="openDetail(p)">
<strong x-text="p.name"></strong>
<div class="clients-card-meta">
<span class="clients-tag" x-text="statusLabel(p.status)"></span>
<span class="clients-tag mrr" x-show="p.margin_pct" x-text="(p.margin_pct||0)+'%'"></span>
</div>
<div class="clients-card-meta" x-show="p.client_name" x-text="p.client_name"></div>
<div class="clients-card-meta" x-show="p.shelf_target" x-text="p.shelf_target"></div>
</article>
</template>
</div>
<p class="clients-empty" x-show="!filtered.length" x-text="$t('products.no_products')"></p>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterStatus" @change="applyFilter()">
<option value="" x-text="$t('common.all')"></option>
<template x-for="s in statuses" :key="s.id">
<option :value="s.id" x-text="s.label"></option>
</template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th x-text="$t('products.name')"></th><th x-text="$t('products.client')"></th>
<th x-text="$t('products.status')"></th><th x-text="$t('products.margin_pct')"></th>
<th x-text="$t('products.moq')"></th><th></th>
</tr></thead>
<tbody>
<template x-for="p in filtered" :key="'l'+p.id">
<tr class="clickable-row" @click="openDetail(p)">
<td><strong x-text="p.name"></strong></td>
<td x-text="p.client_name||'—'"></td>
<td><span class="badge" x-text="statusLabel(p.status)"></span></td>
<td x-text="p.margin_pct!=null?p.margin_pct+'%':'—'"></td>
<td x-text="p.moq||'—'"></td>
<td>
<button class="btn btn-sm" @click.stop="editProduct(p)" x-text="$t('common.edit')"></button>
<button class="btn btn-sm btn-reject" @click.stop="remove(p.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab==='margin'" x-cloak>
<section class="panel store-edit-panel">
<div class="clients-form-grid" style="max-width:480px">
<label><span class="filter-label" x-text="$t('products.calc_cost')"></span>
<input type="number" class="form-input" x-model="margin.cost" step="0.01" /></label>
<label><span class="filter-label" x-text="$t('products.calc_sell')"></span>
<input type="number" class="form-input" x-model="margin.sell" step="0.01" /></label>
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:0.75rem" @click="calcMargin()" x-text="$t('products.calc_run')"></button>
<div x-show="margin.result!=null" style="margin-top:1rem">
<p><span x-text="$t('products.calc_margin')"></span>: <strong x-text="margin.result+'%'"></strong></p>
<p x-show="margin.profit!=null"><span x-text="$t('products.calc_profit')"></span>: <strong x-text="'€'+margin.profit"></strong></p>
</div>
</section>
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="form.id ? $t('common.edit') : $t('products.new')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label" x-text="$t('products.name')"></span>
<input class="form-input" x-model="form.name" /></label>
<label><span class="filter-label" x-text="$t('products.status')"></span>
<select class="form-input" x-model="form.status">
<template x-for="s in statuses" :key="'fs'+s.id"><option :value="s.id" x-text="s.label"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('products.client')"></span>
<select class="form-input" x-model="form.client_id">
<option value="" x-text="$t('common.none')"></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('products.margin_pct')"></span>
<input type="number" class="form-input" x-model="form.margin_pct" step="0.1" /></label>
<label><span class="filter-label" x-text="$t('products.moq')"></span>
<input type="number" class="form-input" x-model="form.moq" /></label>
<label><span class="filter-label" x-text="$t('products.launch_date')"></span>
<input type="date" class="form-input" x-model="form.launch_date" /></label>
<label class="full"><span class="filter-label" x-text="$t('products.shelf_target')"></span>
<input class="form-input" x-model="form.shelf_target" placeholder="Jumbo NL · AH regio" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
</div>
</div>
</div>
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div><h2 x-text="detail?.product?.name||''"></h2>
<p class="muted" x-text="statusLabel(detail?.product?.status)"></p></div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.product">
<div class="clients-card-meta">
<div x-show="detail.product.client_name"><strong x-text="$t('products.drawer_deals')"></strong>: <span x-text="detail.product.client_name"></span></div>
<div x-show="detail.product.margin_pct"><strong x-text="$t('products.margin_pct')"></strong>: <span x-text="detail.product.margin_pct+'%'"></span></div>
<div x-show="detail.product.moq"><strong x-text="$t('products.moq')"></strong>: <span x-text="detail.product.moq"></span></div>
<div x-show="detail.product.shelf_target"><strong x-text="$t('products.shelf_target')"></strong>: <span x-text="detail.product.shelf_target"></span></div>
<div x-show="detail.product.launch_date"><strong x-text="$t('products.launch_date')"></strong>: <span x-text="detail.product.launch_date"></span></div>
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editProduct(detail.product)" x-text="$t('common.edit')"></button>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function productsCrud() {
function productsHub() {
return {
show: false, id: null, form: { name: '', status: 'concept', margin_pct: 0, client_id: null },
margin: { cost: 0, sell: 0, result: null },
edit(id, name, status, margin_pct) { this.id=id; this.form={name,status,margin_pct,client_id:null}; this.show=true; },
async calcMargin() {
const r = await Cockpit.api('/products/margin-calc', { method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }) });
this.margin.result = r.margin_pct;
tab: 'grid', busy: false, modal: false, drawer: false,
stats: {}, products: [], filtered: [], clients: [], detail: null,
filterQ: '', filterStatus: '',
form: {}, margin: { cost: 0, sell: 0, result: null, profit: null },
statuses: [],
onLocale() { this.buildStatuses(); },
buildStatuses() {
this.statuses = [
{ id: 'concept', label: this.$t('products.status_concept') },
{ id: 'development', label: this.$t('products.status_development') },
{ id: 'active', label: this.$t('products.status_active') },
{ id: 'discontinued', label: this.$t('products.status_discontinued') },
];
},
statusLabel(id) {
return (this.statuses.find(s => s.id === id) || {}).label || id || '—';
},
async init() {
this.buildStatuses();
document.addEventListener('foodlinkk:locale', () => this.buildStatuses());
await this.loadAll();
},
async loadAll() {
this.busy = true;
try {
const [statsR, prodR, cliR] = await Promise.all([
Cockpit.api('/products/stats'),
Cockpit.api('/products'),
Cockpit.api('/clients'),
]);
this.stats = statsR.stats || {};
this.products = prodR.items || [];
this.clients = cliR.items || [];
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.products.filter(p => {
if (this.filterStatus && p.status !== this.filterStatus) return false;
if (!q) return true;
return [p.name, p.client_name, p.shelf_target, p.status].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openModal() {
this.form = { id: null, name: '', status: 'concept', client_id: '', margin_pct: '', moq: '', shelf_target: '', launch_date: '' };
this.modal = true;
},
editProduct(p) {
this.form = {
id: p.id, name: p.name || '', status: p.status || 'concept',
client_id: p.client_id || '', margin_pct: p.margin_pct ?? '',
moq: p.moq ?? '', shelf_target: p.shelf_target || '', launch_date: p.launch_date || '',
};
this.modal = true; this.drawer = false;
},
async save() {
const body = { ...this.form, margin_pct: parseFloat(this.form.margin_pct) };
if (this.id) await Cockpit.api('/products/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
location.reload();
const body = {
name: this.form.name.trim(),
status: this.form.status,
client_id: this.form.client_id ? parseInt(this.form.client_id, 10) : null,
margin_pct: this.form.margin_pct !== '' ? parseFloat(this.form.margin_pct) : null,
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
shelf_target: this.form.shelf_target || null,
launch_date: this.form.launch_date || null,
};
try {
if (this.form.id) await Cockpit.api('/products/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
Cockpit.toast(window.I18n.t('common.saved'), 'success');
this.modal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!Cockpit.confirmDelete()) return;
await Cockpit.api('/products/' + id, { method: 'DELETE' });
await this.loadAll();
},
async openDetail(p) {
const d = await Cockpit.api('/products/' + p.id + '/detail');
this.detail = d;
this.drawer = true;
},
async calcMargin() {
const r = await Cockpit.api('/products/margin-calc', {
method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }),
});
this.margin.result = r.margin_pct;
this.margin.profit = r.profit;
},
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/products/' + id, { method: 'DELETE' }); location.reload(); }
};
}
</script>
+344
View File
@@ -0,0 +1,344 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
<link rel="stylesheet" href="/static/css/projects-hub.css?v=1" />
{% endblock %}
{% block content %}
<div class="clients-shell" x-data="projectsHub()" x-init="init()">
<header class="page-header herman-hero">
<div>
<h1>Projecten <span class="live-badge">Food Business</span></h1>
<p class="hero-sub">Gestructureerd per klant · NAS-mappen · foto's · documenten · packaging</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
<a class="btn btn-sm" href="/documents">Documenten →</a>
<a class="btn btn-sm" href="/packaging">Packaging →</a>
</div>
</header>
<div class="clients-kpi-row" style="grid-template-columns:repeat(4,1fr)">
<div class="clients-kpi"><span>Totaal projecten</span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span>Met klant</span><strong x-text="stats.with_client||0"></strong></div>
<div class="clients-kpi"><span>NAS mappen</span><strong x-text="stats.with_nas||0"></strong></div>
<div class="clients-kpi"><span>Assets gekoppeld</span><strong x-text="stats.assets||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">📋 Overzicht</button>
<button class="clients-tab" :class="{active: tab==='create'}" @click="tab='create'"> Nieuw project</button>
<button class="clients-tab" :class="{active: tab==='byclient'}" @click="tab='byclient'">👥 Per klant</button>
</div>
<div x-show="tab==='create'" x-cloak>
<section class="panel store-edit-panel">
<h2 style="margin-top:0">Nieuw food-business project</h2>
<p class="page-tip">Kies een type — per klant wordt automatisch een aparte NAS-map aangemaakt (niet alles op één hoop).</p>
<div class="project-type-grid">
<template x-for="t in types" :key="t.id">
<div class="project-type-card" :class="{selected: form.project_type===t.id}" @click="form.project_type=t.id">
<span class="icon" x-text="t.icon"></span>
<span class="label" x-text="t.label_nl"></span>
</div>
</template>
</div>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Projectnaam *</span>
<input class="form-input" x-model="form.name" placeholder="bv. Jumbo listing halal maaltijden Q3" /></label>
<label><span class="filter-label">Klant *</span>
<select class="form-input" x-model="form.client_id">
<option value="">— Selecteer klant —</option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
<label><span class="filter-label">Prioriteit</span>
<select class="form-input" x-model="form.priority">
<option value="low">Laag</option><option value="normal">Normaal</option>
<option value="high">Hoog</option><option value="urgent">Urgent</option>
</select></label>
<label class="full"><span class="filter-label">Beschrijving</span>
<textarea class="form-input" rows="3" x-model="form.description" placeholder="Doel, deliverables, deadline…"></textarea></label>
</div>
<button class="btn btn-primary" @click="create()" :disabled="!form.name.trim()">Project + NAS-map aanmaken</button>
</section>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek project…" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterType" @change="applyFilter()">
<option value="">Alle types</option>
<template x-for="t in types" :key="'f'+t.id"><option :value="t.id" x-text="t.label_nl"></option></template>
</select>
<select x-model="filterClient" @change="applyFilter()">
<option value="">Alle klanten</option>
<template x-for="c in clients" :key="'fc'+c.id"><option :value="c.id" x-text="c.name"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th>Naam</th><th>Type</th><th>Klant</th><th>Assets</th><th>NAS</th><th>Status</th><th></th>
</tr></thead>
<tbody>
<template x-for="p in filtered" :key="p.id">
<tr class="clickable-row" @click="open(p.id)">
<td><strong x-text="p.name"></strong></td>
<td x-text="typeLabel(p.project_type)"></td>
<td x-text="p.client_name||'—'"></td>
<td x-text="p.asset_count||0"></td>
<td><span class="clients-tag" x-show="p.nas_path">✓ map</span><span x-show="!p.nas_path"></span></td>
<td x-text="p.status"></td>
<td><button class="btn btn-sm" @click.stop="open(p.id)">Open</button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab==='byclient'" x-cloak>
<template x-for="c in clientsWithProjects" :key="c.id">
<section class="panel" style="margin-bottom:0.75rem">
<h3 x-text="c.name"></h3>
<p class="muted" style="font-size:0.75rem" x-show="c.nas_folder" x-text="'NAS: '+c.nas_folder"></p>
<template x-for="p in c.projects" :key="p.id">
<div class="feed-card clickable-row" @click="open(p.id)">
<strong x-text="p.name"></strong>
<small x-text="typeLabel(p.project_type)+' · '+(p.asset_count||0)+' assets'"></small>
</div>
</template>
<p x-show="!c.projects.length" class="muted">Geen projecten — maak er een aan voor deze klant.</p>
</section>
</template>
</div>
<aside class="clients-drawer" :class="{ open: drawer }" style="width:min(560px,96vw)">
<div class="clients-drawer-head">
<div>
<h2 x-text="selected?.name||''"></h2>
<p class="muted" x-text="typeLabel(selected?.project_type)+' · '+(selected?.client_name||'Geen klant')"></p>
</div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="selected">
<div>
<div class="clients-tabs" style="margin-bottom:0.75rem">
<button class="clients-tab" :class="{active: detailTab==='info'}" @click="detailTab='info'">Info</button>
<button class="clients-tab" :class="{active: detailTab==='assets'}" @click="detailTab='assets'">Assets</button>
<button class="clients-tab" :class="{active: detailTab==='photos'}" @click="detailTab='photos'; loadPhotos()">Foto's</button>
<button class="clients-tab" :class="{active: detailTab==='files'}" @click="detailTab='files'; loadNasFiles()">NAS files</button>
</div>
<div x-show="detailTab==='info'">
<p x-text="selected.description||'Geen beschrijving'"></p>
<div x-show="selected.nas_path">
<strong style="font-size:0.72rem;color:#94a3b8">NAS projectmap</strong>
<div class="nas-path-box" x-text="selected.nas_path"></div>
<p class="muted" style="font-size:0.7rem">Structuur: photos/ · documents/ · packaging/ · exports/ · briefs/</p>
</div>
<div x-show="selected.nas_client_root">
<strong style="font-size:0.72rem;color:#94a3b8">Klant NAS root</strong>
<div class="nas-path-box" x-text="selected.nas_client_root"></div>
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="ensureNas()" x-show="!selected.nas_path">NAS-map aanmaken</button>
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Packaging Studio</a>
<a class="btn btn-sm" href="/documents">Documenten hub →</a>
<a class="btn btn-sm" href="/clients">Klant CRM →</a>
</div>
</div>
<div x-show="detailTab==='assets'">
<div class="asset-grid">
<template x-for="a in (selected.assets||[])" :key="a.id">
<div class="asset-card">
<div class="type" x-text="a.asset_type"></div>
<strong x-text="a.title"></strong>
<small x-text="(a.source_agent||a.created_by||'')+' · '+(a.created_at||'').substring(0,10)"></small>
<div class="muted" style="font-size:0.68rem;word-break:break-all" x-show="a.file_path" x-text="a.file_path"></div>
<div class="btn-group" style="margin-top:0.45rem" x-show="a.asset_type==='packaging' && a.ref_id">
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Open in Studio</a>
<a class="btn btn-sm btn-ghost" :href="'/api/packaging/download/'+a.ref_id+'?format=pdf'" target="_blank">PDF</a>
</div>
</div>
</template>
</div>
<p x-show="!(selected.assets||[]).length" class="muted">Nog geen assets — koppel foto's of NAS-bestanden.</p>
</div>
<div x-show="detailTab==='photos'">
<p class="page-tip">Klik een foto om te koppelen aan dit project.</p>
<div class="photo-pick-grid">
<template x-for="ph in photos" :key="ph.id">
<div class="photo-pick-item" @click="linkPhoto(ph.id)">
<img :src="'/api/admin/photos/' + ph.id + '/image.jpg'" :alt="ph.filename" loading="lazy" />
</div>
</template>
</div>
<p x-show="!photos.length" class="muted">Geen foto's — gebruik Browser/Photos om te importeren.</p>
</div>
<div x-show="detailTab==='files'">
<p class="page-tip">Koppel een NAS-bestand aan dit project (gestructureerd in projectmap).</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek bestand…" x-model="fileQ" @input="filterNasFiles()" />
</div>
<template x-for="f in nasFilesFiltered.slice(0,40)" :key="f.storage_path||f.path">
<div class="feed-card clickable-row" @click="linkNasFile(f)">
<strong x-text="f.filename||f.name"></strong>
<small x-text="f.storage_path||f.path||''"></small>
</div>
</template>
<p x-show="!nasFiles.length" class="muted">Geen NAS-bestanden gevonden — check Documenten hub.</p>
</div>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function projectsHub() {
return {
tab: 'list', detailTab: 'info', busy: false, drawer: false,
stats: {}, projects: [], filtered: [], clients: [], types: [],
selected: null, photos: [], nasFiles: [], nasFilesFiltered: [], fileQ: '',
filterQ: '', filterType: '', filterClient: '',
form: { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' },
get clientsWithProjects() {
return this.clients.map(c => ({
...c,
projects: this.projects.filter(p => p.client_id === c.id),
})).filter(c => c.projects.length || true);
},
typeLabel(id) {
return (this.types.find(t => t.id === id) || {}).label_nl || id || '—';
},
async init() {
const q = new URLSearchParams(location.search).get('id');
await Promise.all([this.loadAll(), this.loadClients(), this.loadTypes()]);
if (q) this.open(parseInt(q, 10));
},
async loadTypes() {
const r = await fetch('/api/projects/types').then(x => x.json());
this.types = r.items || [];
},
async loadAll() {
this.busy = true;
try {
const [statsR, projR] = await Promise.all([
fetch('/api/projects/stats').then(x => x.json()),
fetch('/api/projects?limit=200').then(x => x.json()),
]);
this.stats = statsR.stats || {};
this.projects = projR.items || [];
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
async loadClients() {
try {
const r = await Cockpit.api('/clients');
this.clients = r.items || [];
} catch (e) { this.clients = []; }
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.projects.filter(p => {
if (this.filterType && p.project_type !== this.filterType) return false;
if (this.filterClient && String(p.client_id) !== String(this.filterClient)) return false;
if (!q) return true;
return [p.name, p.client_name, p.description].some(f => f && String(f).toLowerCase().includes(q));
});
},
async create() {
if (!this.form.name.trim()) return Cockpit.toast('Naam verplicht', 'error');
if (!this.form.client_id) return Cockpit.toast('Selecteer een klant voor NAS-structuur', 'warn');
await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.form.name,
client_id: parseInt(this.form.client_id, 10),
description: this.form.description,
project_type: this.form.project_type,
priority: this.form.priority,
}),
}).then(r => { if (!r.ok) throw new Error('Aanmaken mislukt'); return r.json(); });
this.form = { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' };
this.tab = 'list';
await this.loadAll();
Cockpit.toast('Project + NAS-map aangemaakt', 'success');
},
async open(id) {
const r = await fetch('/api/projects/' + id).then(x => x.json());
this.selected = r.project;
this.detailTab = 'info';
this.drawer = true;
},
async ensureNas() {
if (!this.selected) return;
const r = await fetch('/api/projects/' + this.selected.id + '/ensure-nas', { method: 'POST' }).then(x => x.json());
Cockpit.toast('NAS-map aangemaakt', 'success');
await this.open(this.selected.id);
},
async loadPhotos() {
try {
const r = await Cockpit.api('/photos?limit=60');
this.photos = r.items || r.photos || [];
} catch (e) { this.photos = []; }
},
async linkPhoto(photoId) {
if (!this.selected) return;
await Cockpit.api('/api/projects/photos/' + photoId + '/link', {
method: 'POST',
body: JSON.stringify({ project_id: this.selected.id }),
});
Cockpit.toast('Foto gekoppeld', 'success');
await this.open(this.selected.id);
await this.loadAll();
},
async loadNasFiles() {
try {
const r = await Cockpit.api('/documents/share-files?limit=200');
this.nasFiles = r.items || r.files || [];
this.filterNasFiles();
} catch (e) { this.nasFiles = []; this.nasFilesFiltered = []; }
},
filterNasFiles() {
const q = (this.fileQ || '').toLowerCase();
this.nasFilesFiltered = this.nasFiles.filter(f => {
const name = (f.filename || f.name || '').toLowerCase();
const path = (f.storage_path || f.path || '').toLowerCase();
return !q || name.includes(q) || path.includes(q);
});
},
async linkNasFile(f) {
if (!this.selected) return;
const path = f.storage_path || f.path;
const title = f.filename || f.name || path;
await fetch('/api/projects/' + this.selected.id + '/link-nas-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, file_path: path, asset_type: 'document' }),
});
Cockpit.toast('Bestand gekoppeld', 'success');
await this.open(this.selected.id);
},
};
}
</script>
{% endblock %}
+230 -20
View File
@@ -1,30 +1,240 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="suppliersCrud()">
<header class="page-header"><h1>Suppliers</h1><p class="subtitle page-tip">Vendor registry with ratings and lead times.</p></header>
<button class="btn btn-primary" @click="openNew()">Add supplier</button>
<table class="data-table"><thead><tr><th>Name</th><th>Country</th><th>Rating</th><th></th></tr></thead>
<tbody>{% for s in suppliers %}<tr class="clickable-row" @click="edit({{ s.id }}, '{{ s.name|e }}', '{{ (s.country or '')|e }}', {{ s.rating or 0 }})">
<td>{{ s.name }}</td><td>{{ s.country }}</td><td>{{ s.rating }}</td>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ s.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.country" /><input type="number" step="0.1" class="form-input" x-model="form.rating" />
<button class="btn btn-primary" @click="save()">Save</button></div></div>
<div class="clients-shell" x-data="suppliersHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('suppliers.title')">Leveranciers</h1>
<p class="subtitle page-tip" x-text="$t('suppliers.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<a class="btn btn-sm" href="/products" x-text="$t('nav.products')"></a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span x-text="$t('suppliers.total')"></span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.avg_rating')"></span><strong x-text="(stats.avg_rating||0)+'/5'"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.countries')"></span><strong x-text="stats.countries||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.avg_lead')"></span><strong x-text="(stats.avg_lead||0)+'d'"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('suppliers.tab_grid')"></button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('suppliers.tab_list')"></button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('suppliers.new')"></button>
</div>
</div>
<div x-show="tab==='grid'" x-cloak>
<div class="agents-neo-grid">
<template x-for="s in filtered" :key="s.id">
<article class="agent-soul-card" @click="openDetail(s)">
<strong x-text="s.name"></strong>
<div class="clients-card-meta" x-text="(s.country||'') + (s.category ? ' · '+categoryLabel(s.category) : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag mrr" x-show="s.rating" x-text="'★ '+(s.rating||0)"></span>
<span class="clients-tag" x-show="s.lead_time_days" x-text="s.lead_time_days+'d'"></span>
</div>
<div class="clients-card-meta" x-show="s.contact" x-text="s.contact"></div>
</article>
</template>
</div>
<p class="clients-empty" x-show="!filtered.length" x-text="$t('suppliers.no_suppliers')"></p>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterCountry" @change="applyFilter()">
<option value="" x-text="$t('suppliers.filter_country') + ' — ' + $t('common.all')"></option>
<template x-for="c in countryOptions" :key="c"><option :value="c" x-text="c"></option></template>
</select>
<select x-model="filterCategory" @change="applyFilter()">
<option value="" x-text="$t('suppliers.filter_category') + ' — ' + $t('common.all')"></option>
<template x-for="cat in categories" :key="cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th x-text="$t('suppliers.name')"></th><th x-text="$t('suppliers.country')"></th>
<th x-text="$t('suppliers.category')"></th><th x-text="$t('suppliers.rating')"></th>
<th x-text="$t('suppliers.lead_time')"></th><th x-text="$t('suppliers.moq')"></th><th></th>
</tr></thead>
<tbody>
<template x-for="s in filtered" :key="'l'+s.id">
<tr class="clickable-row" @click="openDetail(s)">
<td><strong x-text="s.name"></strong></td>
<td x-text="s.country||'—'"></td>
<td x-text="categoryLabel(s.category)"></td>
<td x-text="s.rating!=null?s.rating:'—'"></td>
<td x-text="s.lead_time_days!=null?s.lead_time_days+'d':'—'"></td>
<td x-text="s.moq||'—'"></td>
<td>
<button class="btn btn-sm" @click.stop="editSupplier(s)" x-text="$t('common.edit')"></button>
<button class="btn btn-sm btn-reject" @click.stop="remove(s.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="form.id ? $t('common.edit') : $t('suppliers.new')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label" x-text="$t('suppliers.name')"></span>
<input class="form-input" x-model="form.name" /></label>
<label><span class="filter-label" x-text="$t('suppliers.country')"></span>
<input class="form-input" x-model="form.country" list="country-list" /></label>
<datalist id="country-list"><option>NL</option><option>BE</option><option>DE</option><option>TR</option><option>MA</option></datalist>
<label><span class="filter-label" x-text="$t('suppliers.category')"></span>
<select class="form-input" x-model="form.category">
<option value="" x-text="$t('common.none')"></option>
<template x-for="cat in categories" :key="'fc'+cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('suppliers.rating')"></span>
<input type="number" class="form-input" x-model="form.rating" min="0" max="5" step="0.1" /></label>
<label><span class="filter-label" x-text="$t('suppliers.moq')"></span>
<input type="number" class="form-input" x-model="form.moq" /></label>
<label><span class="filter-label" x-text="$t('suppliers.lead_time')"></span>
<input type="number" class="form-input" x-model="form.lead_time_days" /></label>
<label class="full"><span class="filter-label" x-text="$t('suppliers.contact')"></span>
<textarea class="form-input" rows="3" x-model="form.contact" placeholder="naam@bedrijf.nl · +31…"></textarea></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
<button class="btn btn-reject" x-show="form.id" @click="remove(form.id); modal=false" x-text="$t('common.delete')"></button>
</div>
</div>
</div>
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div><h2 x-text="detail?.supplier?.name||''"></h2>
<p class="muted" x-text="(detail?.supplier?.country||'') + ' · ' + categoryLabel(detail?.supplier?.category)"></p></div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.supplier">
<div class="clients-card-meta">
<div x-show="detail.supplier.rating"><strong x-text="$t('suppliers.rating')"></strong>: <span x-text="detail.supplier.rating+'/5'"></span></div>
<div x-show="detail.supplier.moq"><strong x-text="$t('suppliers.moq')"></strong>: <span x-text="detail.supplier.moq"></span></div>
<div x-show="detail.supplier.lead_time_days"><strong x-text="$t('suppliers.lead_time')"></strong>: <span x-text="detail.supplier.lead_time_days+' dagen'"></span></div>
<div x-show="detail.supplier.contact" style="white-space:pre-wrap;margin-top:0.5rem" x-text="detail.supplier.contact"></div>
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editSupplier(detail.supplier)" x-text="$t('common.edit')"></button>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function suppliersCrud() {
return { show: false, id: null, form: { name: '', country: '', rating: 4, category: '', moq: null, lead_time_days: null, contact: '' },
openNew() { this.id=null; this.form={ name:'', country:'', rating:4, category:'', moq:null, lead_time_days:null, contact:'' }; this.show=true; },
edit(id,name,country,rating) { this.id=id; this.form={name,country,rating,category:'',moq:null,lead_time_days:null,contact:''}; this.show=true; },
async save() {
const body = { ...this.form, rating: parseFloat(this.form.rating) };
if (this.id) await Cockpit.api('/suppliers/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
location.reload();
function suppliersHub() {
return {
tab: 'grid', busy: false, modal: false, drawer: false,
stats: {}, suppliers: [], filtered: [], detail: null,
filterQ: '', filterCountry: '', filterCategory: '',
form: {}, categories: [], countryOptions: [],
buildCategories() {
this.categories = [
{ id: 'Meat', label: this.$t('suppliers.cat_meat') },
{ id: 'Dairy', label: this.$t('suppliers.cat_dairy') },
{ id: 'Spices', label: this.$t('suppliers.cat_spices') },
{ id: 'Packaging', label: this.$t('suppliers.cat_packaging') },
{ id: 'Logistics', label: this.$t('suppliers.cat_logistics') },
{ id: 'Ingredients', label: this.$t('suppliers.cat_ingredients') },
{ id: 'Other', label: this.$t('suppliers.cat_other') },
];
},
categoryLabel(id) {
if (!id) return '—';
return (this.categories.find(c => c.id === id) || {}).label || id;
},
async init() {
this.buildCategories();
document.addEventListener('foodlinkk:locale', () => this.buildCategories());
await this.loadAll();
},
async loadAll() {
this.busy = true;
try {
const [statsR, supR] = await Promise.all([
Cockpit.api('/suppliers/stats'),
Cockpit.api('/suppliers'),
]);
this.stats = statsR.stats || {};
this.suppliers = supR.items || [];
const countries = new Set();
this.suppliers.forEach(s => { if (s.country) countries.add(s.country); });
this.countryOptions = Array.from(countries).sort();
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.suppliers.filter(s => {
if (this.filterCountry && s.country !== this.filterCountry) return false;
if (this.filterCategory && s.category !== this.filterCategory) return false;
if (!q) return true;
return [s.name, s.country, s.category, s.contact].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openModal() {
this.form = { id: null, name: '', country: 'NL', category: '', rating: 4, moq: '', lead_time_days: '', contact: '' };
this.modal = true;
},
editSupplier(s) {
this.form = {
id: s.id, name: s.name || '', country: s.country || '', category: s.category || '',
rating: s.rating ?? 4, moq: s.moq ?? '', lead_time_days: s.lead_time_days ?? '', contact: s.contact || '',
};
this.modal = true; this.drawer = false;
},
async save() {
const body = {
name: this.form.name.trim(),
country: this.form.country || null,
category: this.form.category || null,
rating: this.form.rating !== '' ? parseFloat(this.form.rating) : null,
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
lead_time_days: this.form.lead_time_days !== '' ? parseInt(this.form.lead_time_days, 10) : null,
contact: this.form.contact || null,
};
try {
if (this.form.id) await Cockpit.api('/suppliers/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
Cockpit.toast(window.I18n.t('common.saved'), 'success');
this.modal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!Cockpit.confirmDelete()) return;
await Cockpit.api('/suppliers/' + id, { method: 'DELETE' });
await this.loadAll();
},
async openDetail(s) {
const d = await Cockpit.api('/suppliers/' + s.id + '/detail');
this.detail = d;
this.drawer = true;
},
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/suppliers/' + id, { method: 'DELETE' }); location.reload(); }
};
}
</script>