SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
+211
-13
@@ -3,7 +3,7 @@
|
||||
<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=13" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
|
||||
<script src="/static/js/agent-avatars.js?v=3"></script>
|
||||
{% endblock %}
|
||||
@@ -25,12 +25,16 @@
|
||||
|
||||
<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'">
|
||||
<div class="vtabs-nav-header"><strong>Agents</strong></div>
|
||||
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
|
||||
<span class="vtab-icon">🧬</span><span>Souls</span>
|
||||
</button>
|
||||
<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==='terminals'}" @click="setTab('terminals', $el)" data-tab="terminals">
|
||||
<span class="vtab-icon">🖥️</span><span>Terminals</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>
|
||||
@@ -62,6 +66,9 @@
|
||||
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
|
||||
<p class="agent-role-desc" x-text="s.responsibilities || '—'"></p>
|
||||
<div class="agent-meta-row">
|
||||
<button type="button" class="agent-events-link"
|
||||
@click.stop="goTerminal(s.agent_key)"
|
||||
title="Open live terminal">Terminal →</button>
|
||||
<button type="button" class="agent-events-link"
|
||||
@click.stop="openEvents(s.agent_key, s.display_name)"
|
||||
x-text="(s.event_count||0) + ' events'"
|
||||
@@ -105,18 +112,114 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="tab==='mesh'" x-cloak>
|
||||
<section x-show="tab==='mesh'" x-cloak
|
||||
x-effect="if (tab === 'mesh') { $nextTick(() => mountMesh()) }">
|
||||
<div class="panel">
|
||||
<h2 style="margin-top:0;">Netwerk</h2>
|
||||
<p class="page-tip" style="margin-top:.25rem;">Agents onderaan → Herman (Co-CEO) → CEO & CTO. Pulse = actieve communicatie.</p>
|
||||
<div class="mesh-framework-head">
|
||||
<h2 style="margin-top:0;">Agent Communicatie Framework</h2>
|
||||
<p class="page-tip" style="margin-top:.25rem;">Kies hoe je het netwerk wilt zien. Klik een agent → live terminal. Alle terminals onder tab <strong>Terminals</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="mesh-mode-section" id="mesh-mode-picker">
|
||||
<label>Communicatiestijl</label>
|
||||
<div class="mesh-mode-grid">
|
||||
<button type="button" class="mesh-mode-card" data-mesh-mode="pyramid" aria-pressed="false"
|
||||
onclick="window.AgentsMesh&&AgentsMesh.pickMode('pyramid')">
|
||||
<span class="mesh-mode-check" aria-hidden="true"></span>
|
||||
<span class="mesh-mode-icon">◆</span>
|
||||
<span class="mesh-mode-title">Piramide</span>
|
||||
<span class="mesh-mode-sub">Herman als hub</span>
|
||||
<span class="mesh-mode-desc">Rapport ↑ en delegatie ↓. Alleen live handoffs.</span>
|
||||
</button>
|
||||
<button type="button" class="mesh-mode-card" data-mesh-mode="mesh" aria-pressed="false"
|
||||
onclick="window.AgentsMesh&&AgentsMesh.pickMode('mesh')">
|
||||
<span class="mesh-mode-check" aria-hidden="true"></span>
|
||||
<span class="mesh-mode-icon">⬡</span>
|
||||
<span class="mesh-mode-title">Vol netwerk</span>
|
||||
<span class="mesh-mode-sub">Alle partners</span>
|
||||
<span class="mesh-mode-desc">Volledige samenwerkingsmatrix zichtbaar.</span>
|
||||
</button>
|
||||
<button type="button" class="mesh-mode-card" data-mesh-mode="org" aria-pressed="false"
|
||||
onclick="window.AgentsMesh&&AgentsMesh.pickMode('org')">
|
||||
<span class="mesh-mode-check" aria-hidden="true"></span>
|
||||
<span class="mesh-mode-icon">▤</span>
|
||||
<span class="mesh-mode-title">Organigram</span>
|
||||
<span class="mesh-mode-sub">Pure hiërarchie</span>
|
||||
<span class="mesh-mode-desc">CEO → Herman → agents, geen peer-lijnen.</span>
|
||||
</button>
|
||||
<button type="button" class="mesh-mode-card" data-mesh-mode="pulse" aria-pressed="false"
|
||||
onclick="window.AgentsMesh&&AgentsMesh.pickMode('pulse')">
|
||||
<span class="mesh-mode-check" aria-hidden="true"></span>
|
||||
<span class="mesh-mode-icon">◎</span>
|
||||
<span class="mesh-mode-title">Live stream</span>
|
||||
<span class="mesh-mode-sub">Alleen actief</span>
|
||||
<span class="mesh-mode-desc">Geen stille lijnen — alleen wat nu beweegt.</span>
|
||||
</button>
|
||||
<button type="button" class="mesh-mode-card" data-mesh-mode="clusters" aria-pressed="false"
|
||||
onclick="window.AgentsMesh&&AgentsMesh.pickMode('clusters')">
|
||||
<span class="mesh-mode-check" aria-hidden="true"></span>
|
||||
<span class="mesh-mode-icon">◉</span>
|
||||
<span class="mesh-mode-title">Domeinen</span>
|
||||
<span class="mesh-mode-sub">Per afdeling</span>
|
||||
<span class="mesh-mode-desc">Groei · Product · Operatie gegroepeerd.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mesh-toolbar">
|
||||
<span id="mesh-mode-badge" class="mesh-mode-badge">Piramide</span>
|
||||
<button type="button" class="btn btn-sm" @click="remountMesh()">↻ Verversen</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="setTab('terminals', $refs.vtabsNav?.querySelector('[data-tab=terminals]'))">Alle terminals</button>
|
||||
</div>
|
||||
<div class="mesh-wrap">
|
||||
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
|
||||
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1200 860"></svg>
|
||||
</div>
|
||||
<div class="mesh-legend">
|
||||
<span>Groen = healthy</span>
|
||||
<span>Oranje = waarschuwing</span>
|
||||
<span>Blauw = idle</span>
|
||||
<span>Grijs = offline</span>
|
||||
<span class="leg-report">↑ Rapport</span>
|
||||
<span class="leg-delegate">↓ Delegatie</span>
|
||||
<span class="leg-peer" x-show="meshLegendPeer">↔ Handoff</span>
|
||||
<span class="leg-exec" x-show="meshLegendExec">⇅ Executive</span>
|
||||
</div>
|
||||
<div class="mesh-terminal-focus" x-show="focusTerminalKey" x-cloak>
|
||||
<div class="agent-term-head">
|
||||
<span class="term-dot"></span>
|
||||
<strong x-text="'Terminal · ' + (focusTerminalName || '')"></strong>
|
||||
<button type="button" class="btn btn-sm" @click="clearFocusTerminal()">Sluiten</button>
|
||||
</div>
|
||||
<div class="agent-term-body" :data-term-key="focusTerminalKey" data-term-focus="1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="tab==='terminals'" x-cloak>
|
||||
<div class="panel">
|
||||
<h2 style="margin-top:0;">Agent terminals</h2>
|
||||
<p class="agents-terminals-intro">Elke agent heeft een eigen terminal. Typ commando's en druk <kbd>Enter</kbd> — output verschijnt live. Start met <code>help</code>.</p>
|
||||
<div class="agents-terminals-grid">
|
||||
<template x-for="s in souls" :key="'term-' + s.agent_key">
|
||||
<article class="agent-term-window"
|
||||
:class="{ 'is-live': (s.events_6h||0) > 0 || s.current_task, 'is-expanded': expandedTerminal === s.agent_key }"
|
||||
@click="expandTerminal(s.agent_key)">
|
||||
<header class="agent-term-head">
|
||||
<span class="term-dot"></span>
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<small x-text="s.current_task || s.last_event_title || 'Standby'"></small>
|
||||
</header>
|
||||
<div class="agent-term-body" :data-term-key="s.agent_key"></div>
|
||||
<footer class="agent-term-foot" @click.stop>
|
||||
<label class="agent-term-cmd" :for="'term-in-' + s.agent_key">
|
||||
<span class="term-prompt" x-text="s.agent_key + '>'"></span>
|
||||
<input type="text"
|
||||
:id="'term-in-' + s.agent_key"
|
||||
class="agent-term-input"
|
||||
:data-term-input="s.agent_key"
|
||||
:placeholder="s.agent_key === 'herman' ? 'briefing · vraag aan Herman…' : (s.agent_key === 'webbuilder' ? 'build <opdracht> · projects · status …' : 'help · status · handoff …')"
|
||||
autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</label>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -196,7 +299,8 @@
|
||||
{% 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 src="/static/js/agents-mesh.js?v=11"></script>
|
||||
<script src="/static/js/agents-terminals.js?v=6"></script>
|
||||
<script>
|
||||
function agentsSoulHub() {
|
||||
return {
|
||||
@@ -207,6 +311,11 @@ function agentsSoulHub() {
|
||||
pendingCount: 0,
|
||||
souls: [], selected: null, form: {}, saving: false,
|
||||
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
|
||||
terminalOpen: false, terminalAgentKey: null, terminalAgentName: '', terminalLines: [], _terminalEs: null,
|
||||
meshCommMode: 'pyramid',
|
||||
meshLegendPeer: true,
|
||||
meshLegendExec: true,
|
||||
focusTerminalKey: null, focusTerminalName: '', expandedTerminal: 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; },
|
||||
statusClass(s) {
|
||||
@@ -260,6 +369,7 @@ function agentsSoulHub() {
|
||||
return 'Standby';
|
||||
},
|
||||
async init() {
|
||||
this.syncMeshMode();
|
||||
await this.load();
|
||||
await this.loadPendingCount();
|
||||
if (window.PageViz) {
|
||||
@@ -268,7 +378,17 @@ function agentsSoulHub() {
|
||||
this.$nextTick(() => {
|
||||
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
|
||||
if (activeBtn) this.updateTabIndicator(activeBtn);
|
||||
const self = this;
|
||||
if (window.AgentsMesh) {
|
||||
AgentsMesh.bindModePicker({
|
||||
onNodeClick: function (node) {
|
||||
self.focusTerminal(node.agent_key, node.display_name);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (window.AgentsTerminals) AgentsTerminals.startLiveStream();
|
||||
if (this.tab === 'mesh') this.mountMesh();
|
||||
if (this.tab === 'terminals') this.startTerminals();
|
||||
});
|
||||
},
|
||||
updateTabIndicator(el) {
|
||||
@@ -285,6 +405,7 @@ function agentsSoulHub() {
|
||||
url.searchParams.set('tab', next);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
if (next === 'mesh') this.mountMesh();
|
||||
if (next === 'terminals') this.startTerminals();
|
||||
if (next === 'approvals') this.loadPendingCount();
|
||||
},
|
||||
async loadPendingCount() {
|
||||
@@ -293,10 +414,87 @@ function agentsSoulHub() {
|
||||
this.pendingCount = (r.items || []).length;
|
||||
} catch (e) { this.pendingCount = 0; }
|
||||
},
|
||||
syncMeshMode() {
|
||||
if (!window.AgentsMesh) return;
|
||||
const self = this;
|
||||
this.meshCommMode = AgentsMesh.getSavedMode();
|
||||
this.updateMeshLegend();
|
||||
AgentsMesh.setModeChangeCallback(function (id) {
|
||||
self.meshCommMode = id;
|
||||
self.updateMeshLegend();
|
||||
});
|
||||
},
|
||||
updateMeshLegend() {
|
||||
const m = (window.AgentsMesh && AgentsMesh.COMM_MODES) ? AgentsMesh.COMM_MODES[this.meshCommMode] : null;
|
||||
this.meshLegendPeer = m ? (m.showLivePeers || m.showStaticPeers) : true;
|
||||
this.meshLegendExec = m ? m.showExecutive !== false : true;
|
||||
},
|
||||
setMeshMode(id) {
|
||||
if (window.AgentsMesh) AgentsMesh.pickMode(id);
|
||||
this.meshCommMode = id;
|
||||
this.updateMeshLegend();
|
||||
},
|
||||
remountMesh() {
|
||||
if (window.AgentsMesh && AgentsMesh.refresh) {
|
||||
const self = this;
|
||||
AgentsMesh.refresh('agents-mesh-canvas', {
|
||||
commMode: this.meshCommMode,
|
||||
onNodeClick: function (node) {
|
||||
self.focusTerminal(node.agent_key, node.display_name);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.mountMesh();
|
||||
}
|
||||
},
|
||||
mountMesh() {
|
||||
if (this.meshLoaded || !window.AgentsMesh) return;
|
||||
this.meshLoaded = true;
|
||||
window.AgentsMesh.mount('agents-mesh-canvas');
|
||||
if (!window.AgentsMesh) return;
|
||||
const self = this;
|
||||
const opts = {
|
||||
commMode: this.meshCommMode,
|
||||
onNodeClick: function (node) {
|
||||
self.focusTerminal(node.agent_key, node.display_name);
|
||||
},
|
||||
};
|
||||
window.AgentsMesh.mount('agents-mesh-canvas', opts).catch(function (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Mesh: ' + (e.message || 'laden mislukt'), 'error');
|
||||
});
|
||||
if (this.focusTerminalKey && window.AgentsTerminals) {
|
||||
this.$nextTick(function () { AgentsTerminals.mountAll(); });
|
||||
}
|
||||
},
|
||||
focusTerminal(key, name) {
|
||||
this.focusTerminalKey = key;
|
||||
this.focusTerminalName = name || key;
|
||||
this.expandedTerminal = key;
|
||||
this.$nextTick(function () {
|
||||
if (window.AgentsTerminals) AgentsTerminals.mountAll();
|
||||
});
|
||||
},
|
||||
clearFocusTerminal() {
|
||||
this.focusTerminalKey = null;
|
||||
this.focusTerminalName = '';
|
||||
},
|
||||
goTerminal(key) {
|
||||
const soul = this.souls.find(function (s) { return s.agent_key === key; });
|
||||
const btn = this.$refs.vtabsNav?.querySelector('[data-tab=terminals]');
|
||||
this.setTab('terminals', btn);
|
||||
this.expandedTerminal = key;
|
||||
this.$nextTick(function () {
|
||||
const el = document.querySelector('[data-term-key="' + key + '"]');
|
||||
if (el) el.closest('.agent-term-window')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
},
|
||||
expandTerminal(key) {
|
||||
this.expandedTerminal = this.expandedTerminal === key ? null : key;
|
||||
},
|
||||
startTerminals() {
|
||||
this.$nextTick(function () {
|
||||
if (window.AgentsTerminals) {
|
||||
AgentsTerminals.mountAll();
|
||||
AgentsTerminals.bindInputs();
|
||||
}
|
||||
});
|
||||
},
|
||||
async load() {
|
||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
||||
|
||||
+198
-57
@@ -8,71 +8,74 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
|
||||
<title>{% block title %}{{ page_title }} · Foodlinkk{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json?v=1" />
|
||||
<title>{% block title %}{{ page_title }} · Cucina{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json?v=2" />
|
||||
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
|
||||
<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>
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=8" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=18" />
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" />
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen }">
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
|
||||
<div class="app-sidebar-brand">
|
||||
<p class="brand-title">Foodlinkk</p>
|
||||
<p class="brand-name">Command Center</p>
|
||||
<p class="brand-title">Cucina · Foodlinkk</p>
|
||||
<p class="brand-name" x-text="brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'">Cucina B2C</p>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
|
||||
<div class="persona-switch brand-switch" role="tablist" aria-label="Brand">
|
||||
<button type="button" role="tab" class="persona-btn brand-btn-cucina"
|
||||
:class="{ active: brand === 'cucina' }" @click="setBrand('cucina')"
|
||||
:aria-selected="brand === 'cucina'">Cucina · B2C</button>
|
||||
<button type="button" role="tab" class="persona-btn brand-btn-foodlinkk"
|
||||
:class="{ active: brand === 'foodlinkk' }" @click="setBrand('foodlinkk')"
|
||||
:aria-selected="brand === 'foodlinkk'">Foodlinkk · B2B</button>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<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="persona-nav">
|
||||
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
|
||||
<div class="app-sidebar-label">Foodlinkk B2B</div>
|
||||
<a href="/export-intel" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
|
||||
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}"><span class="sidebar-nav-icon">💬</span><span class="sidebar-nav-label" data-i18n="nav.chat">Herman</span></a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<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" x-show="brand === 'cucina'">
|
||||
<div class="app-sidebar-label">Cucina B2C</div>
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
|
||||
<a href="/revenue-cockpit" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
|
||||
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<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" 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" 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 class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -81,7 +84,10 @@
|
||||
<div class="app-main">
|
||||
<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>
|
||||
<span class="page-tip">
|
||||
<span class="persona-badge" :class="brand === 'foodlinkk' ? 'brand-badge-foodlinkk' : 'brand-badge-cucina'" x-text="brand === 'foodlinkk' ? 'B2B' : 'B2C'"></span>
|
||||
Cucina · 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>
|
||||
@@ -99,15 +105,117 @@
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/i18n.js?v=2"></script>
|
||||
<!-- Herman browser assistant (elke pagina) -->
|
||||
<div x-data="hermanBrowserWidget()" x-init="initWidget()" x-show="!hidden" x-cloak>
|
||||
<button type="button" class="ha-fab" :class="{ 'is-open': open }" @click="toggleOpen()" title="Praat met Herman" aria-label="Herman assistant">💬</button>
|
||||
<div class="ha-panel" x-show="open" x-transition @click.outside="open = false">
|
||||
<header class="ha-head">
|
||||
<h2>Herman <span style="font-size:0.65rem;color:#a78bfa">browser</span></h2>
|
||||
<div class="ha-head-actions">
|
||||
<a href="/voice">Voice live</a>
|
||||
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="ha-confirm" x-show="pendingAction">
|
||||
<span x-text="pendingAction ? (pendingAction.type === 'webbuilder_build' ? ('Agy website: ' + (pendingAction.project || '')) : ('Zoeken: ' + (pendingAction.entity_label || '') + ' in ' + (pendingAction.location_label || ''))) : ''"></span>
|
||||
<div class="ha-confirm-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="confirmPending()" :disabled="busy">Ja</button>
|
||||
<button type="button" class="btn btn-sm" @click="cancelPending()" :disabled="busy">Nee</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ha-log" x-ref="chatLog">
|
||||
<template x-for="(t, i) in turns" :key="i">
|
||||
<div class="ha-bubble" :class="t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'">
|
||||
<div class="ha-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : ' · Herman')"></div>
|
||||
<p x-text="t.text"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!turns.length" class="page-tip" style="font-size:0.75rem;margin:0">Typ of spreek — bv. “zoek distributeurs in Dubai”</p>
|
||||
</div>
|
||||
<div class="ha-preview" :class="{ 'is-interim': liveInterim }" x-show="livePreview || recording" x-text="livePreview || 'Luisteren…'"></div>
|
||||
<p class="ha-status" x-show="statusDetail" x-text="statusDetail"></p>
|
||||
<div class="ha-input-row">
|
||||
<button type="button" class="ha-mic" :class="micClass()"
|
||||
@mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()"
|
||||
@touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()"
|
||||
@click.prevent="onMicClick()" title="Microfoon">🎙️</button>
|
||||
<input class="form-input" type="text" placeholder="Vraag aan Herman…" x-model="manualText"
|
||||
@keydown.enter.prevent="sendText()" :disabled="busy" />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="sendText()" :disabled="busy || !manualText.trim()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-modal-root ha-results-modal" x-show="resultsOpen" @click.self="closeResultsModal()">
|
||||
<div class="ei-modal-card ha-results-card" @click.stop>
|
||||
<h3 x-text="resultsTitle"></h3>
|
||||
<p><strong x-text="resultsTotal"></strong> resultaten</p>
|
||||
<div class="voice-results-table-wrap" style="max-height:40vh;overflow:auto">
|
||||
<table class="voice-results-table" style="width:100%;font-size:0.78rem">
|
||||
<thead><tr><th>Naam</th><th>Type</th><th>Stad</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(e, i) in resultsEntities" :key="i">
|
||||
<tr>
|
||||
<td><a :href="'/export-intel?open=' + e.id" x-text="e.name"></a></td>
|
||||
<td x-text="entityTypeLabel(e.entity_type)"></td>
|
||||
<td x-text="e.city || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-modal-actions">
|
||||
<a class="btn btn-sm" :href="resultsOpenUrl">Wereldexport</a>
|
||||
<button type="button" class="btn btn-sm" @click="closeResultsModal()">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-modal-root ha-results-modal" x-show="webbuilderOpen" @click.self="closeWebbuilderModal()">
|
||||
<div class="ei-modal-card ha-results-card" @click.stop>
|
||||
<h3 x-text="webbuilderTitle"></h3>
|
||||
<p><strong x-text="webbuilderProject"></strong> · Agy / Antigravity op Hermes</p>
|
||||
<p class="page-tip" style="font-size:0.78rem">Preview na afloop: <a :href="webbuilderPreview" target="_blank" rel="noopener" x-text="webbuilderPreview"></a></p>
|
||||
<p class="page-tip" x-show="webbuilderNas" style="font-size:0.72rem" x-text="'NAS: ' + webbuilderNas"></p>
|
||||
<div class="ei-modal-actions">
|
||||
<a class="btn btn-sm btn-pulse" :href="webbuilderAgentsUrl">Agents terminal</a>
|
||||
<a class="btn btn-sm" :href="webbuilderPreview" target="_blank" rel="noopener">Preview</a>
|
||||
<button type="button" class="btn btn-sm" @click="closeWebbuilderModal()">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="/static/js/herman-assistant.js?v=2"></script>
|
||||
|
||||
<script src="/static/js/i18n.js?v=5"></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>
|
||||
window.FoodlinkkBrand = (function () {
|
||||
var FOODLINKK_PREFIXES = ['/export-intel', '/foodlinkk', '/clients', '/deals', '/products', '/suppliers'];
|
||||
var CUCINA_PREFIXES = ['/retail', '/marketing', '/packaging', '/revenue-cockpit', '/beurs'];
|
||||
function isFoodlinkkPath(path) {
|
||||
return FOODLINKK_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
|
||||
}
|
||||
function isCucinaPath(path) {
|
||||
return path === '/' || CUCINA_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
|
||||
}
|
||||
function detect(path) {
|
||||
var stored = localStorage.getItem('foodlinkk-brand');
|
||||
if (stored === 'cucina' || stored === 'foodlinkk') return stored;
|
||||
if (isFoodlinkkPath(path)) return 'foodlinkk';
|
||||
return 'cucina';
|
||||
}
|
||||
return { isFoodlinkkPath: isFoodlinkkPath, isCucinaPath: isCucinaPath, detect: detect };
|
||||
})();
|
||||
|
||||
/* legacy persona helper → brand */
|
||||
window.FoodlinkkPersona = window.FoodlinkkBrand;
|
||||
|
||||
function appShell() {
|
||||
return {
|
||||
locale: 'nl',
|
||||
brand: 'cucina',
|
||||
drawerOpen: false,
|
||||
navOpen: false,
|
||||
showInstall: false,
|
||||
@@ -116,11 +224,37 @@
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
async initApp() {
|
||||
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
||||
if (!localStorage.getItem('flk-cache-v3')) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
}
|
||||
if (window.caches) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
localStorage.setItem('flk-cache-v3', '1');
|
||||
}
|
||||
if (window.I18n) await window.I18n.init();
|
||||
this.locale = window.I18n ? window.I18n.locale() : 'nl';
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
},
|
||||
setBrand(b) {
|
||||
if (b === this.brand) return;
|
||||
this.brand = b;
|
||||
localStorage.setItem('foodlinkk-brand', b);
|
||||
var path = location.pathname;
|
||||
if (b === 'foodlinkk' && window.FoodlinkkBrand.isCucinaPath(path) && !window.FoodlinkkBrand.isFoodlinkkPath(path)) {
|
||||
location.href = '/export-intel';
|
||||
return;
|
||||
}
|
||||
if (b === 'cucina' && window.FoodlinkkBrand.isFoodlinkkPath(path)) {
|
||||
location.href = '/';
|
||||
return;
|
||||
}
|
||||
},
|
||||
async setLocale(loc) {
|
||||
if (window.I18n) await window.I18n.setLocale(loc);
|
||||
this.locale = loc;
|
||||
@@ -148,9 +282,6 @@
|
||||
this._deferredPrompt = e;
|
||||
this.showInstall = true;
|
||||
});
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
|
||||
}
|
||||
},
|
||||
async installPwa() {
|
||||
if (this._deferredPrompt) {
|
||||
@@ -171,6 +302,16 @@
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(function () {
|
||||
if (window.Alpine) return;
|
||||
document.querySelectorAll('[x-cloak]').forEach(function (el) { el.removeAttribute('x-cloak'); });
|
||||
document.body.classList.add('alpine-missing');
|
||||
console.error('Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).');
|
||||
}, 2500);
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+473
-13
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/parse-intel.css?v=1" />
|
||||
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
|
||||
<style>
|
||||
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
@@ -8,6 +9,36 @@
|
||||
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
|
||||
}
|
||||
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
|
||||
.parse-result-card { border: 1px solid rgba(148,163,184,.14); border-radius: 10px; padding: .75rem .85rem; margin-bottom: .65rem; background: rgba(7,11,18,.6); }
|
||||
.parse-result-card header { display:flex; flex-direction:column; gap:.15rem; margin-bottom:.45rem; }
|
||||
.parse-excerpt { color:#94a3b8; font-size:.82rem; line-height:1.45; margin:0 0 .45rem; }
|
||||
.parse-headings { display:flex; flex-wrap:wrap; gap:.35rem; margin-bottom:.4rem; }
|
||||
.parse-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(0,229,255,.08); color:#7dd3fc; border:1px solid rgba(0,229,255,.2); }
|
||||
.parse-links { margin:.25rem 0 0; padding-left:1rem; font-size:.78rem; color:#cbd5e1; }
|
||||
.parse-links a { color:#86efac; }
|
||||
.intel-kpi-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.65rem; margin-bottom:1rem; }
|
||||
@media (max-width:900px){ .intel-kpi-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.intel-kpi { padding:.75rem; border-radius:10px; border:1px solid rgba(148,163,184,.14); background:rgba(7,11,18,.65); }
|
||||
.intel-kpi .lbl { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.intel-kpi .val { font-size:1.35rem; font-weight:700; color:#f1f5f9; margin-top:.15rem; }
|
||||
.intel-toolbar { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:1rem; }
|
||||
.intel-hype-cloud { display:flex; flex-wrap:wrap; gap:.45rem; margin:.5rem 0 1rem; }
|
||||
.hype-chip { font-size:.78rem; padding:.25rem .6rem; border-radius:999px; border:1px solid rgba(168,85,247,.35); background:rgba(168,85,247,.12); color:#e9d5ff; cursor:default; }
|
||||
.hype-chip.cross { border-color:rgba(255,215,0,.45); background:rgba(255,215,0,.1); color:#fde68a; box-shadow:0 0 12px rgba(255,215,0,.12); }
|
||||
.hype-chip .score { opacity:.65; font-size:.65rem; margin-left:.25rem; }
|
||||
.intel-page-card { border:1px solid rgba(148,163,184,.16); border-radius:12px; margin-bottom:.85rem; background:rgba(6,10,16,.85); overflow:hidden; }
|
||||
.intel-page-head { display:flex; flex-wrap:wrap; gap:.5rem; align-items:flex-start; justify-content:space-between; padding:.85rem 1rem; border-bottom:1px solid rgba(148,163,184,.1); cursor:pointer; }
|
||||
.intel-page-head:hover { background:rgba(0,229,255,.04); }
|
||||
.intel-page-body { padding:0 1rem 1rem; }
|
||||
.intel-signals { display:flex; flex-wrap:wrap; gap:.35rem; margin:.5rem 0; }
|
||||
.signal-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(34,197,94,.12); color:#86efac; border:1px solid rgba(34,197,94,.25); }
|
||||
.signal-chip.hot { background:rgba(245,158,11,.12); color:#fcd34d; border-color:rgba(245,158,11,.35); }
|
||||
.intel-read-text { max-height:320px; overflow:auto; white-space:pre-wrap; font-size:.8rem; line-height:1.55; color:#cbd5e1; background:rgba(0,0,0,.35); padding:.85rem; border-radius:8px; border:1px solid rgba(148,163,184,.1); }
|
||||
.intel-read-text.expanded { max-height:560px; }
|
||||
.intel-section-title { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:#64748b; margin:1rem 0 .4rem; font-weight:700; }
|
||||
.intel-trend-table { width:100%; font-size:.82rem; }
|
||||
.intel-trend-table td, .intel-trend-table th { padding:.35rem .5rem; border-bottom:1px solid rgba(148,163,184,.08); text-align:left; }
|
||||
.hype-score { font-size:.75rem; color:#f59e0b; font-weight:700; }
|
||||
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
|
||||
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
|
||||
.browser-live-row {
|
||||
@@ -77,9 +108,10 @@
|
||||
<div class="browser-tabs">
|
||||
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
|
||||
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
|
||||
<button type="button" class="browser-tab" :class="{ active: tab === 'analyse' }" @click="setTab('analyse')">Parse Analyse</button>
|
||||
</div>
|
||||
|
||||
<div class="browser-live-row" style="margin-bottom:1rem">
|
||||
<div class="browser-live-row" style="margin-bottom:1rem" x-show="tab === 'research'">
|
||||
<section class="panel browser-live-panel">
|
||||
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
|
||||
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
|
||||
@@ -187,7 +219,7 @@
|
||||
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
|
||||
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
|
||||
<button class="btn btn-primary" type="submit">Toevoegen</button>
|
||||
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
|
||||
<button type="button" class="btn btn-secondary" @click="crawlAll()" :disabled="crawlBusy" x-text="crawlBusy ? 'Crawlen…' : 'Alles crawlen'"></button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel">
|
||||
@@ -199,7 +231,7 @@
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
|
||||
<td>{{ s.last_crawled or '—' }}</td>
|
||||
<td class="btn-group">
|
||||
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
|
||||
<button class="btn btn-sm" @click="crawlOne({{ s.id }})" :disabled="crawlBusy">Crawl</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
|
||||
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
|
||||
</td></tr>{% else %}
|
||||
@@ -211,17 +243,262 @@
|
||||
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Crawl logs</h2>
|
||||
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
|
||||
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% else %}<li class="empty-state">Nog geen crawls</li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel" x-show="parseResults.length" x-cloak>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem">
|
||||
<div>
|
||||
<h2 style="margin:0">Parse-overzicht (kort)</h2>
|
||||
<p class="page-tip" style="margin:.25rem 0 0">Voor volledige analyse → tab <button type="button" class="btn btn-sm btn-pulse" @click="setTab('analyse')">Parse Analyse</button></p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm" @click="loadParseResults()">↻ Verversen</button>
|
||||
</div>
|
||||
<template x-for="p in parseResults" :key="p.id">
|
||||
<article class="parse-result-card">
|
||||
<header>
|
||||
<strong x-text="p.title || p.url"></strong>
|
||||
<span class="muted" x-text="'#' + (p.site_id||'—') + ' · ' + (p.word_count||0) + ' woorden · ' + (p.links_count||0) + ' links'"></span>
|
||||
</header>
|
||||
<p class="parse-excerpt" x-text="p.excerpt || p.meta_description || '—'"></p>
|
||||
<div class="parse-headings" x-show="(p.headings||[]).length">
|
||||
<template x-for="h in (p.headings||[]).slice(0,5)" :key="h">
|
||||
<span class="parse-chip" x-text="h"></span>
|
||||
</template>
|
||||
</div>
|
||||
<ul class="parse-links" x-show="(p.links_sample||[]).length">
|
||||
<template x-for="lnk in (p.links_sample||[]).slice(0,6)" :key="lnk.href">
|
||||
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
|
||||
</template>
|
||||
</ul>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'analyse'" x-cloak class="pi-workbench">
|
||||
<header class="pi-header">
|
||||
<div>
|
||||
<h2>Parse Intelligence Workbench</h2>
|
||||
<p class="page-tip" style="margin:0.25rem 0 0">Filter · sorteer · klik hype-termen · exporteer — alles wat gecrawld is</p>
|
||||
</div>
|
||||
<div class="pi-header-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="loadIntelligence()" :disabled="intelLoading">↻ Ververs</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="exportIntelCsv()" x-show="intelFilteredPages.length">⬇ CSV</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="crawlAndAnalyse()" :disabled="crawlBusy || intelLoading">Crawl + analyse</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pi-kpi-strip" x-show="intel && intel.summary">
|
||||
<div class="pi-kpi-cell">
|
||||
<div class="lbl">Gefilterd</div>
|
||||
<div class="val" x-text="intelFilteredPages.length + '/' + (intel.pages||[]).length">0</div>
|
||||
<div class="sub">pagina's zichtbaar</div>
|
||||
</div>
|
||||
<div class="pi-kpi-cell">
|
||||
<div class="lbl">Woorden</div>
|
||||
<div class="val" x-text="intelFilteredWordSum">0</div>
|
||||
<div class="sub">in selectie</div>
|
||||
</div>
|
||||
<div class="pi-kpi-cell">
|
||||
<div class="lbl">Hype-termen</div>
|
||||
<div class="val" x-text="(intel.hype_terms||[]).length">0</div>
|
||||
<div class="sub">klik om te filteren</div>
|
||||
</div>
|
||||
<div class="pi-kpi-cell">
|
||||
<div class="lbl">Gewijzigd 7d</div>
|
||||
<div class="val" x-text="intel.summary.changes_7d">0</div>
|
||||
<div class="sub">shift-signalen</div>
|
||||
</div>
|
||||
<div class="pi-kpi-cell">
|
||||
<div class="lbl">Gem. hype</div>
|
||||
<div class="val" x-text="intelAvgHype">0</div>
|
||||
<div class="sub">score selectie</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pi-loading" x-show="intelLoading">Analyse laden…</div>
|
||||
|
||||
<div class="pi-body" :class="{ 'detail-open': intelSelectedId }" x-show="intel && !intelLoading">
|
||||
<aside class="pi-filters">
|
||||
<label>Zoek in alle geparste data</label>
|
||||
<input type="search" class="pi-search" placeholder="halal, curry, bidfood…" x-model="intelSearchLocal" @input.debounce.250ms="" />
|
||||
|
||||
<label>Site</label>
|
||||
<select class="pi-select" x-model="intelSiteId">
|
||||
<option value="">Alle sites</option>
|
||||
<template x-for="s in intelSiteOptions" :key="s.id">
|
||||
<option :value="s.id" x-text="s.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<label>Min. woorden <span x-text="intelMinWords"></span></label>
|
||||
<input type="range" class="pi-range" min="0" max="3000" step="50" x-model.number="intelMinWords" />
|
||||
|
||||
<label>Min. hype-score <span x-text="intelMinHype"></span></label>
|
||||
<input type="range" class="pi-range" min="0" max="80" step="5" x-model.number="intelMinHype" />
|
||||
|
||||
<label class="pi-toggle-row"><input type="checkbox" x-model="intelChangedOnly" /> Alleen recent gewijzigd</label>
|
||||
|
||||
<label>Sorteren</label>
|
||||
<select class="pi-select" x-model="intelSortCol" @change="">
|
||||
<option value="hype_score">Hype score</option>
|
||||
<option value="word_count">Woorden</option>
|
||||
<option value="crawled_at">Datum crawl</option>
|
||||
<option value="site_name">Site</option>
|
||||
<option value="title">Titel</option>
|
||||
<option value="links_count">Links</option>
|
||||
</select>
|
||||
<select class="pi-select" style="margin-top:0.35rem" x-model="intelSortDir">
|
||||
<option value="desc">Aflopend ↓</option>
|
||||
<option value="asc">Oplopend ↑</option>
|
||||
</select>
|
||||
|
||||
<div class="pi-filter-chips" x-show="intelActiveTerms.length || intelChangedOnly || intelMinWords > 0">
|
||||
<template x-for="t in intelActiveTerms" :key="t">
|
||||
<span class="pi-filter-chip">term: <span x-text="t"></span><button type="button" @click="removeIntelTerm(t)">×</button></span>
|
||||
</template>
|
||||
<span class="pi-filter-chip" x-show="intelChangedOnly">gewijzigd<button type="button" @click="intelChangedOnly=false">×</button></span>
|
||||
<button type="button" class="btn btn-sm" style="margin-top:0.35rem;width:100%" @click="clearIntelFilters()">Filters wissen</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="pi-main">
|
||||
<div class="pi-trends-bar" x-show="(intel.hype_terms||[]).length">
|
||||
<div class="lbl">Hype-termen — klik om te filteren (goud = cross-site)</div>
|
||||
<div class="pi-hype-cloud">
|
||||
<template x-for="t in (intel.hype_terms||[]).slice(0,35)" :key="t.term">
|
||||
<span class="pi-hype-chip" :class="{ cross: t.cross_site, active: isIntelTermActive(t.term) }"
|
||||
@click="toggleIntelTerm(t.term)" :title="'Sites: ' + (t.sites||[]).join(', ')">
|
||||
<span x-text="t.term"></span><span class="sc" x-text="t.score"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pi-toolbar">
|
||||
<span class="count"><strong x-text="intelFilteredPages.length"></strong> resultaten</span>
|
||||
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'hype_score' }" @click="sortIntelBy('hype_score')">Hype</button>
|
||||
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'word_count' }" @click="sortIntelBy('word_count')">Woorden</button>
|
||||
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'crawled_at' }" @click="sortIntelBy('crawled_at')">Datum</button>
|
||||
<div class="pi-view-toggle">
|
||||
<button type="button" :class="{ active: intelView === 'table' }" @click="intelView='table'">Tabel</button>
|
||||
<button type="button" :class="{ active: intelView === 'cards' }" @click="intelView='cards'">Kaarten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pi-empty" x-show="!intelFilteredPages.length">
|
||||
<strong>Geen matches</strong>
|
||||
Pas filters aan, wis hype-termen, of crawl eerst onder Site Monitor.
|
||||
</div>
|
||||
|
||||
<div class="pi-table-wrap" x-show="intelView === 'table' && intelFilteredPages.length">
|
||||
<table class="pi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="sortIntelBy('site_name')">Site <span class="arrow" x-show="intelSortCol==='site_name'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
|
||||
<th @click="sortIntelBy('title')">Titel <span class="arrow" x-show="intelSortCol==='title'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
|
||||
<th @click="sortIntelBy('word_count')" class="num">Woorden <span class="arrow" x-show="intelSortCol==='word_count'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
|
||||
<th @click="sortIntelBy('hype_score')" class="num">Hype <span class="arrow" x-show="intelSortCol==='hype_score'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
|
||||
<th @click="sortIntelBy('links_count')" class="num">Links</th>
|
||||
<th>Signalen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="p in intelFilteredPages" :key="p.id">
|
||||
<tr :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
|
||||
<td class="site-cell" x-text="p.site_name"></td>
|
||||
<td class="title-cell" :title="p.title || p.url" x-text="p.title || p.url"></td>
|
||||
<td class="num" x-text="p.word_count"></td>
|
||||
<td class="num"><span class="pi-badge hype-high" x-text="p.hype_score"></span></td>
|
||||
<td class="num" x-text="p.links_count"></td>
|
||||
<td>
|
||||
<span class="pi-badge changed" x-show="p.recently_changed">Δ</span>
|
||||
<span class="muted" style="font-size:.7rem" x-text="(p.signals||[])[0]||''"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pi-cards" x-show="intelView === 'cards' && intelFilteredPages.length">
|
||||
<template x-for="p in intelFilteredPages" :key="'c-'+p.id">
|
||||
<article class="pi-card" :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
|
||||
<div class="site-cell" x-text="p.site_name"></div>
|
||||
<strong style="font-size:.85rem;display:block;margin:.25rem 0" x-text="p.title||p.url"></strong>
|
||||
<div class="muted" style="font-size:.72rem" x-text="(p.word_count||0)+' wo · hype '+ (p.hype_score||0)"></div>
|
||||
<p style="font-size:.75rem;color:#94a3b8;margin:.4rem 0 0;line-height:1.4" x-text="(p.excerpt||'').slice(0,120)+'…'"></p>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="pi-detail">
|
||||
<template x-if="!intelSelectedPage">
|
||||
<div class="pi-detail-empty">← Klik een rij om de volledige parse te lezen<br><small>Highlight op actieve zoektermen</small></div>
|
||||
</template>
|
||||
<template x-if="intelSelectedPage">
|
||||
<div style="display:flex;flex-direction:column;height:100%">
|
||||
<header class="pi-detail-head">
|
||||
<h3 x-text="intelSelectedPage.site_name + ' — ' + (intelSelectedPage.title || 'Pagina')"></h3>
|
||||
<div class="pi-detail-meta">
|
||||
<span x-text="(intelSelectedPage.word_count||0) + ' woorden'"></span> ·
|
||||
<span x-text="'hype ' + (intelSelectedPage.hype_score||0)"></span> ·
|
||||
<span x-text="(intelSelectedPage.crawled_at||'').substring(0,16).replace('T',' ')"></span>
|
||||
</div>
|
||||
<div class="intel-signals" style="margin-top:.5rem">
|
||||
<template x-for="sig in (intelSelectedPage.signals||[])" :key="sig">
|
||||
<span class="signal-chip" :class="{ hot: sig.indexOf('gewijzigd')>=0 }" x-text="sig"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div style="margin-top:.5rem;display:flex;gap:.4rem;flex-wrap:wrap">
|
||||
<a class="btn btn-sm btn-secondary" :href="intelSelectedPage.final_url || intelSelectedPage.url" target="_blank" rel="noopener">Live URL ↗</a>
|
||||
<button type="button" class="btn btn-sm" @click="loadFullPage(intelSelectedPage.id)">Volledige tekst</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="pi-detail-tabs">
|
||||
<button type="button" :class="{ active: intelDetailTab === 'text' }" @click="intelDetailTab='text'">Tekst</button>
|
||||
<button type="button" :class="{ active: intelDetailTab === 'headings' }" @click="intelDetailTab='headings'">Koppen</button>
|
||||
<button type="button" :class="{ active: intelDetailTab === 'links' }" @click="intelDetailTab='links'">Links</button>
|
||||
</div>
|
||||
<div class="pi-detail-body">
|
||||
<div x-show="intelDetailTab === 'text'" class="pi-read-text" x-html="intelDetailHtml"></div>
|
||||
<div x-show="intelDetailTab === 'headings'" class="pi-heading-list">
|
||||
<template x-for="h in (intelSelectedPage.headings||[])" :key="h">
|
||||
<div class="pi-heading-item" x-text="h"></div>
|
||||
</template>
|
||||
<p class="muted" x-show="!(intelSelectedPage.headings||[]).length">Geen koppen gevonden</p>
|
||||
</div>
|
||||
<ul x-show="intelDetailTab === 'links'" class="pi-link-list">
|
||||
<template x-for="lnk in (intelSelectedPage.links_sample||[])" :key="lnk.href">
|
||||
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="pi-empty" x-show="!intel && !intelLoading">
|
||||
<strong>Nog geen data</strong>
|
||||
Voeg sites toe onder Site Monitor, crawl, en kom terug — of klik Crawl + analyse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/parse-intel.js?v=1"></script>
|
||||
<script>
|
||||
function browserPage() {
|
||||
return {
|
||||
tab: window.location.hash === '#monitor' ? 'monitor' : 'research',
|
||||
tab: (function () {
|
||||
const h = window.location.hash;
|
||||
if (h === '#monitor') return 'monitor';
|
||||
if (h === '#analyse') return 'analyse';
|
||||
return 'research';
|
||||
})(),
|
||||
url: {{ default_url | tojson }},
|
||||
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
|
||||
scrollPages: 6,
|
||||
@@ -240,6 +517,78 @@ function browserPage() {
|
||||
session: null,
|
||||
recent: [],
|
||||
monitorForm: { url: '', name: '' },
|
||||
parseResults: [],
|
||||
crawlBusy: false,
|
||||
intel: null,
|
||||
intelLoading: false,
|
||||
intelSearchLocal: '',
|
||||
intelSiteId: '',
|
||||
intelSortCol: 'hype_score',
|
||||
intelSortDir: 'desc',
|
||||
intelView: 'table',
|
||||
intelMinWords: 0,
|
||||
intelMinHype: 0,
|
||||
intelChangedOnly: false,
|
||||
intelActiveTerms: [],
|
||||
intelSelectedId: null,
|
||||
intelDetailTab: 'text',
|
||||
intelFullPage: null,
|
||||
fullPageText: '',
|
||||
|
||||
get intelFilteredPages() {
|
||||
if (!this.intel || !window.ParseIntel) return [];
|
||||
const pages = ParseIntel.filterPages(this.intel.pages || [], {
|
||||
search: this.intelSearchLocal,
|
||||
terms: this.intelActiveTerms,
|
||||
siteId: this.intelSiteId,
|
||||
minWords: this.intelMinWords,
|
||||
minHype: this.intelMinHype,
|
||||
changedOnly: this.intelChangedOnly,
|
||||
});
|
||||
return ParseIntel.sortPages(pages, this.intelSortCol, this.intelSortDir);
|
||||
},
|
||||
|
||||
get intelSelectedPage() {
|
||||
const id = this.intelSelectedId;
|
||||
if (!id) return null;
|
||||
const all = this.intel && this.intel.pages ? this.intel.pages : [];
|
||||
let p = all.find(function (x) { return x.id === id; });
|
||||
if (p && this.intelFullPage === id && this.fullPageText) {
|
||||
p = Object.assign({}, p, { content_read: this.fullPageText });
|
||||
}
|
||||
return p || null;
|
||||
},
|
||||
|
||||
get intelDetailHtml() {
|
||||
const p = this.intelSelectedPage;
|
||||
if (!p || !window.ParseIntel) return '';
|
||||
const text = (this.intelFullPage === p.id && this.fullPageText) ? this.fullPageText : (p.content_read || p.excerpt || '');
|
||||
const terms = (this.intelActiveTerms || []).slice();
|
||||
if ((this.intelSearchLocal || '').trim()) terms.push(this.intelSearchLocal.trim());
|
||||
return ParseIntel.highlightHtml(text, terms);
|
||||
},
|
||||
|
||||
get intelFilteredWordSum() {
|
||||
return (this.intelFilteredPages || []).reduce(function (s, p) { return s + (p.word_count || 0); }, 0);
|
||||
},
|
||||
|
||||
get intelAvgHype() {
|
||||
const rows = this.intelFilteredPages || [];
|
||||
if (!rows.length) return 0;
|
||||
const sum = rows.reduce(function (s, p) { return s + (p.hype_score || 0); }, 0);
|
||||
return Math.round(sum / rows.length);
|
||||
},
|
||||
|
||||
get intelSiteOptions() {
|
||||
const seen = {};
|
||||
const out = [];
|
||||
(this.intel && this.intel.pages ? this.intel.pages : []).forEach(function (p) {
|
||||
if (!p.site_id || seen[p.site_id]) return;
|
||||
seen[p.site_id] = true;
|
||||
out.push({ id: String(p.site_id), name: p.site_name || ('Site ' + p.site_id) });
|
||||
});
|
||||
return out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||
},
|
||||
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
|
||||
statusText: 'Klaar — voer een URL in of klik Browse nu.',
|
||||
pollTimer: null,
|
||||
@@ -257,13 +606,22 @@ function browserPage() {
|
||||
|
||||
setTab(t) {
|
||||
this.tab = t;
|
||||
history.replaceState(null, '', t === 'monitor' ? '/browser#monitor' : '/browser');
|
||||
const hash = t === 'monitor' ? '#monitor' : (t === 'analyse' ? '#analyse' : '');
|
||||
history.replaceState(null, '', hash ? '/browser' + hash : '/browser');
|
||||
if (t === 'monitor') this.loadParseResults();
|
||||
if (t === 'analyse') this.loadIntelligence();
|
||||
},
|
||||
|
||||
init() {
|
||||
if (window.location.hash === '#monitor') this.tab = 'monitor';
|
||||
if (window.location.hash === '#analyse') this.tab = 'analyse';
|
||||
this.$watch('tab', (t) => {
|
||||
if (t === 'monitor') this.loadParseResults();
|
||||
if (t === 'analyse') this.loadIntelligence();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.tab = window.location.hash === '#monitor' ? 'monitor' : 'research';
|
||||
const h = window.location.hash;
|
||||
this.tab = h === '#monitor' ? 'monitor' : (h === '#analyse' ? 'analyse' : 'research');
|
||||
});
|
||||
this.loadRecent();
|
||||
this.startPoll();
|
||||
@@ -475,7 +833,7 @@ function browserPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
|
||||
});
|
||||
Cockpit.toast('Site toegevoegd — tab Monitor', 'success');
|
||||
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
|
||||
this.newSiteUrl = '';
|
||||
this.newSiteName = '';
|
||||
this.setTab('monitor');
|
||||
@@ -534,17 +892,119 @@ function browserPage() {
|
||||
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
|
||||
try {
|
||||
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
|
||||
Cockpit.toast('Site toegevoegd', 'success');
|
||||
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
|
||||
this.reloadPage();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async loadParseResults() {
|
||||
try {
|
||||
const r = await Cockpit.api('/monitor/parse-results?limit=12');
|
||||
this.parseResults = r.items || [];
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
async loadIntelligence() {
|
||||
this.intelLoading = true;
|
||||
try {
|
||||
this.intel = await Cockpit.api('/monitor/intelligence?limit=100');
|
||||
if (this.intelSelectedId) {
|
||||
const still = (this.intel.pages || []).some(function (p) { return p.id === this.intelSelectedId; }.bind(this));
|
||||
if (!still) {
|
||||
this.intelSelectedId = null;
|
||||
this.intelFullPage = null;
|
||||
this.fullPageText = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message || 'Analyse laden mislukt', 'error');
|
||||
} finally {
|
||||
this.intelLoading = false;
|
||||
}
|
||||
},
|
||||
sortIntelBy(col) {
|
||||
if (this.intelSortCol === col) {
|
||||
this.intelSortDir = this.intelSortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.intelSortCol = col;
|
||||
this.intelSortDir = col === 'title' || col === 'site_name' ? 'asc' : 'desc';
|
||||
}
|
||||
},
|
||||
toggleIntelTerm(term) {
|
||||
term = String(term || '').toLowerCase();
|
||||
if (!term) return;
|
||||
const idx = this.intelActiveTerms.indexOf(term);
|
||||
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
|
||||
else this.intelActiveTerms.push(term);
|
||||
},
|
||||
isIntelTermActive(term) {
|
||||
return this.intelActiveTerms.indexOf(String(term).toLowerCase()) >= 0;
|
||||
},
|
||||
removeIntelTerm(term) {
|
||||
const idx = this.intelActiveTerms.indexOf(term);
|
||||
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
|
||||
},
|
||||
clearIntelFilters() {
|
||||
this.intelSearchLocal = '';
|
||||
this.intelSiteId = '';
|
||||
this.intelMinWords = 0;
|
||||
this.intelMinHype = 0;
|
||||
this.intelChangedOnly = false;
|
||||
this.intelActiveTerms = [];
|
||||
},
|
||||
selectIntelPage(id) {
|
||||
this.intelSelectedId = id;
|
||||
this.intelDetailTab = 'text';
|
||||
this.intelFullPage = null;
|
||||
this.fullPageText = '';
|
||||
},
|
||||
exportIntelCsv() {
|
||||
if (!window.ParseIntel || !this.intelFilteredPages.length) return;
|
||||
ParseIntel.downloadCsv(this.intelFilteredPages, 'foodlinkk-parse-' + new Date().toISOString().slice(0, 10) + '.csv');
|
||||
Cockpit.toast('CSV gedownload', 'success');
|
||||
},
|
||||
async crawlAndAnalyse() {
|
||||
await this.crawlAll();
|
||||
const self = this;
|
||||
setTimeout(function () { self.loadIntelligence(); }, 2800);
|
||||
},
|
||||
async loadFullPage(id) {
|
||||
try {
|
||||
const r = await Cockpit.api('/monitor/parse-results/' + id);
|
||||
this.intelFullPage = id;
|
||||
this.fullPageText = (r.page && (r.page.content_full || r.page.content_read)) || '';
|
||||
Cockpit.toast('Volledige tekst geladen', 'success');
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
formatCrawlToast(result) {
|
||||
const rows = result.results || [];
|
||||
if (!rows.length) return result.message || 'Geen sites om te crawlen';
|
||||
return rows.map(function (r) {
|
||||
if (r.status === 'ERROR') return '✗ ' + (r.name || r.url) + ': ' + (r.error || 'fout');
|
||||
return (r.changed ? '△ ' : '✓ ') + (r.title || r.name) + ' — ' + (r.word_count || 0) + ' woorden';
|
||||
}).join('\n');
|
||||
},
|
||||
async crawlAll() {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
|
||||
if (this.crawlBusy) return;
|
||||
this.crawlBusy = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
|
||||
await this.loadParseResults();
|
||||
setTimeout(() => this.reloadPage(), 1500);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
finally { this.crawlBusy = false; }
|
||||
},
|
||||
async crawlOne(id) {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
|
||||
if (this.crawlBusy) return;
|
||||
this.crawlBusy = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
|
||||
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
|
||||
await this.loadParseResults();
|
||||
setTimeout(() => this.reloadPage(), 1500);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
finally { this.crawlBusy = false; }
|
||||
},
|
||||
async toggleSite(id) {
|
||||
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
|
||||
|
||||
+471
-50
@@ -1,13 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=5" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
<h1>CRM · Klanten</h1>
|
||||
<p class="subtitle page-tip">Klanten beheren · pipeline · supermarkten · 360° intelligence</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
|
||||
@@ -24,15 +24,26 @@
|
||||
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<nav class="clients-tabs" aria-label="Klanten weergaven">
|
||||
<div class="clients-tab-bar">
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'">
|
||||
<span class="clients-tab-icon" aria-hidden="true">▦</span> Kanban
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">
|
||||
<span class="clients-tab-icon" aria-hidden="true">☰</span> Lijst
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; if(!storeResults.length) searchStores(true)">
|
||||
<span class="clients-tab-icon" aria-hidden="true">🛒</span> Supermarkten
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='360'}" @click="open360Tab()">
|
||||
<span class="clients-tab-icon" aria-hidden="true">◎</span> 360° Klant
|
||||
</button>
|
||||
</div>
|
||||
<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-primary btn-sm" @click="openClientModal()">+ Nieuwe klant</button>
|
||||
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div x-show="tab==='kanban'" x-cloak>
|
||||
@@ -52,6 +63,11 @@
|
||||
<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>
|
||||
<select class="clients-card-stage" x-model="c.stage" @click.stop @focus="stageSelectFocus(c)" @change.stop="quickStage(c)" :title="'Status wijzigen voor '+c.name">
|
||||
<template x-for="st in stages" :key="'kb'+c.id+st.id">
|
||||
<option :value="st.id" x-text="st.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -83,7 +99,13 @@
|
||||
<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 @click.stop>
|
||||
<select class="clients-inline-stage" x-model="c.stage" @focus="stageSelectFocus(c)" @change="quickStage(c)">
|
||||
<template x-for="st in stages" :key="'ls'+c.id+st.id">
|
||||
<option :value="st.id" x-text="st.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</td>
|
||||
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
|
||||
<td x-text="c.store_count||0"></td>
|
||||
<td>
|
||||
@@ -99,35 +121,120 @@
|
||||
|
||||
<!-- 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>
|
||||
<p class="page-tip" style="margin-bottom:0.75rem">Filter filialen, selecteer een rij en vul data aan of koppel aan een klant.</p>
|
||||
|
||||
<div class="store-filters-card">
|
||||
<div class="store-filters-grid">
|
||||
<label class="store-filter-field store-filter-search">
|
||||
<span class="filter-label">Zoeken</span>
|
||||
<input type="search" class="form-input" placeholder="Naam, adres, stad…" x-model="storeQ" @keydown.enter="searchStores(true)" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Keten</span>
|
||||
<select class="form-input" x-model="storeChain" @change="searchStores(true)">
|
||||
<option value="">Alle ketens</option>
|
||||
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Provincie</span>
|
||||
<select class="form-input" x-model="storeProvince" @change="searchStores(true)">
|
||||
<option value="">Alle provincies</option>
|
||||
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Stad</span>
|
||||
<input type="text" class="form-input" placeholder="bv. Amsterdam" x-model="storeCity" @keydown.enter="searchStores(true)" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Partnership</span>
|
||||
<select class="form-input" x-model="storePartnership" @change="searchStores(true)">
|
||||
<option value="">Alle statussen</option>
|
||||
<template x-for="p in storePartnerships" :key="p.partnership_status">
|
||||
<option :value="p.partnership_status" x-text="(p.partnership_status||'none')+' ('+p.n+')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Sorteren</span>
|
||||
<select class="form-input" x-model="storeSort" @change="searchStores(true)">
|
||||
<option value="name">Naam A-Z</option>
|
||||
<option value="chain">Keten + naam</option>
|
||||
<option value="halal_opportunity">Halal kans ↓</option>
|
||||
<option value="population">Bevolking ↓</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="store-filters-extra">
|
||||
<summary>Extra filters</summary>
|
||||
<div class="store-filter-checks">
|
||||
<label><input type="checkbox" x-model="storeHalalGap" @change="searchStores(true)" /> Halal-gap (geen cert + geen schap)</label>
|
||||
<label><input type="checkbox" x-model="storeHalalCert" @change="searchStores(true)" /> Halal gecertificeerd</label>
|
||||
<label><input type="checkbox" x-model="storeHalalSection" @change="searchStores(true)" /> Halal schap</label>
|
||||
<label><input type="checkbox" x-model="storeLinkedCrm" @change="searchStores(true)" /> Gekoppeld aan CRM</label>
|
||||
<label><input type="checkbox" x-model="storeHasPhone" @change="searchStores(true)" /> Heeft telefoon</label>
|
||||
<label><input type="checkbox" x-model="storeHasEmail" @change="searchStores(true)" /> Heeft e-mail</label>
|
||||
<label><input type="checkbox" x-model="storeHasManager" @change="searchStores(true)" /> Heeft manager</label>
|
||||
</div>
|
||||
<div class="store-filters-grid" style="margin-top:0.65rem">
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Min. halal-kans (0-100)</span>
|
||||
<input type="number" min="0" max="100" class="form-input" x-model.number="storeMinHalalOpp" @change="searchStores(true)" placeholder="bv. 35" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Postcode (prefix)</span>
|
||||
<input type="text" class="form-input" x-model="storePostcode" @keydown.enter="searchStores(true)" placeholder="bv. 1012" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="store-filters-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="searchStores(true)" :disabled="storeBusy">Zoeken</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="resetStoreFilters()" :disabled="storeBusy">Filters wissen</button>
|
||||
<span class="store-results-meta" x-show="storeTotal > 0" x-text="storeResultLabel()"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-search-panel">
|
||||
<div class="store-results-col">
|
||||
<div class="store-table-wrap">
|
||||
<table class="data-table store-results-table" x-show="storeResults.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Keten</th>
|
||||
<th>Filiaal</th>
|
||||
<th>Stad</th>
|
||||
<th>Status</th>
|
||||
<th>Halal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="s in storeResults" :key="s.id">
|
||||
<tr class="store-table-row" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
|
||||
<td x-text="s.chain||'—'"></td>
|
||||
<td><strong x-text="s.name||'—'"></strong></td>
|
||||
<td x-text="(s.city||'—') + (s.province ? ', '+s.province : '')"></td>
|
||||
<td><span class="clients-tag" x-text="s.partnership_status||'none'"></span></td>
|
||||
<td>
|
||||
<span class="clients-tag" x-show="s.halal_certified">Cert</span>
|
||||
<span class="clients-tag" x-show="s.has_halal_section && !s.halal_certified">Schap</span>
|
||||
<span class="muted" x-show="!s.halal_certified && !s.has_halal_section">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen filialen — pas filters aan en klik Zoeken.</p>
|
||||
<p class="clients-empty" x-show="storeBusy">Zoeken…</p>
|
||||
</div>
|
||||
|
||||
<div class="store-pagination" x-show="storeTotal > storePageSize">
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="storePrevPage()" :disabled="storePage <= 1 || storeBusy">← Vorige</button>
|
||||
<span class="muted" x-text="'Pagina '+storePage+' / '+storePageCount()"></span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="storeNextPage()" :disabled="storePage >= storePageCount() || storeBusy">Volgende →</button>
|
||||
</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>
|
||||
@@ -188,6 +295,99 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 360° Klant Intelligence -->
|
||||
<div x-show="tab==='360'" x-cloak class="crm-360-shell">
|
||||
<div class="crm-360-head">
|
||||
<div>
|
||||
<h2>360° Klant Intelligence</h2>
|
||||
<p class="page-tip">NAS-documenten · sentiment · retail · deals · second brain · <span x-text="autoSyncLabel"></span></p>
|
||||
</div>
|
||||
<div class="crm-360-actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
|
||||
<button class="btn btn-primary btn-sm" @click="linkNasFile()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel pad</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="linkNasFolder()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel map</button>
|
||||
<a class="btn btn-sm" href="/documents" target="_blank">📂 Documenten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-toolbar">
|
||||
<select class="form-input" x-model.number="view360ClientId" @change="loadClient360()">
|
||||
<option value="">— Klant —</option>
|
||||
<template x-for="c in clients" :key="'v360c'+c.id">
|
||||
<option :value="c.id" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select class="form-input" x-model.number="linkProjectId">
|
||||
<option value="">— Project (optioneel) —</option>
|
||||
<template x-for="p in linkProjectsForClient()" :key="'v360p'+p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="form-input" placeholder="NAS pad (bv. Telegram/rapport.pdf of CUCINA/…)" x-model="linkPathInput" />
|
||||
</div>
|
||||
|
||||
<div class="a360-grid" x-show="view360ClientId">
|
||||
<div class="a360-kpis">
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Gekoppelde paden</p>
|
||||
<div class="a360-links">
|
||||
<template x-for="l in client360.links || []" :key="'lnk'+l.id">
|
||||
<div class="a360-link-row">
|
||||
<span x-text="(l.is_folder ? '📁 ' : '📄 ') + l.storage_path"></span>
|
||||
<button class="btn btn-sm btn-secondary" @click="unlinkDocument(l.id)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="clients-empty" x-show="!(client360.links||[]).length">Nog geen koppelingen — voeg een NAS-pad toe of open Documenten Workbench</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Sentiment & trends</p>
|
||||
<div class="a360-sentiment">
|
||||
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
|
||||
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="a360-trends">
|
||||
<template x-for="t in client360.monthly_trends || []" :key="t.month">
|
||||
<div class="a360-trend-row">
|
||||
<span x-text="(t.month||'').slice(0,7)"></span>
|
||||
<span x-text="t.docs + ' docs'"></span>
|
||||
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Top woorden (klant corpus)</p>
|
||||
<div class="a360-words">
|
||||
<template x-for="w in client360.top_words || []" :key="w.lemma">
|
||||
<span class="a360-word" x-text="w.lemma + ' (' + w.total + ')'"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Retail & deals</p>
|
||||
<template x-for="s in client360.stores || []" :key="'st'+s.id">
|
||||
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
|
||||
</template>
|
||||
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
|
||||
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="clients-empty" x-show="!view360ClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
|
||||
</div>
|
||||
|
||||
<!-- Client modal -->
|
||||
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
@@ -270,6 +470,7 @@
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:0.75rem">
|
||||
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="openClient360(detail.client)">360° view →</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>
|
||||
@@ -338,10 +539,34 @@ function clientsHub() {
|
||||
storeQ: '',
|
||||
storeChain: '',
|
||||
storeProvince: '',
|
||||
storeCity: '',
|
||||
storePartnership: '',
|
||||
storeSort: 'name',
|
||||
storePostcode: '',
|
||||
storeMinHalalOpp: null,
|
||||
storeHalalGap: false,
|
||||
storeHalalCert: false,
|
||||
storeHalalSection: false,
|
||||
storeLinkedCrm: false,
|
||||
storeHasPhone: false,
|
||||
storeHasEmail: false,
|
||||
storeHasManager: false,
|
||||
storePage: 1,
|
||||
storePageSize: 25,
|
||||
storeTotal: 0,
|
||||
chains: [],
|
||||
provinces: [],
|
||||
storePartnerships: [],
|
||||
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
|
||||
|
||||
view360ClientId: '',
|
||||
linkProjectId: '',
|
||||
linkPathInput: '',
|
||||
linkProjects: [],
|
||||
client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
|
||||
autoSyncBusy: false,
|
||||
autoSyncLabel: 'Second brain: —',
|
||||
|
||||
fmtEur(v) {
|
||||
if (v == null || v === '') return '—';
|
||||
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
|
||||
@@ -352,7 +577,17 @@ function clientsHub() {
|
||||
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 init() {
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const tab = qs.get('tab');
|
||||
const clientId = qs.get('client');
|
||||
await this.loadAll();
|
||||
if (tab === '360' || tab === 'view360') {
|
||||
this.tab = '360';
|
||||
if (clientId) this.view360ClientId = parseInt(clientId, 10) || '';
|
||||
await this.on360Tab();
|
||||
}
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
@@ -366,6 +601,7 @@ function clientsHub() {
|
||||
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.storePartnerships = filtersR.partnerships || [];
|
||||
this.applyFilter();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
@@ -439,34 +675,219 @@ function clientsHub() {
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
openClient360(c) {
|
||||
if (!c || !c.id) return;
|
||||
this.view360ClientId = c.id;
|
||||
this.tab = '360';
|
||||
this.drawer = false;
|
||||
this.on360Tab();
|
||||
},
|
||||
|
||||
async open360Tab() {
|
||||
this.tab = '360';
|
||||
await this.on360Tab();
|
||||
},
|
||||
|
||||
async on360Tab() {
|
||||
if (this.view360ClientId) await this.loadClient360();
|
||||
await this.refreshAutoSyncStatus();
|
||||
},
|
||||
|
||||
stat360(key) {
|
||||
const stats = this.client360 && this.client360.stats;
|
||||
if (!stats || stats[key] == null) return 0;
|
||||
return stats[key];
|
||||
},
|
||||
|
||||
linkProjectsForClient() {
|
||||
if (!this.view360ClientId) return [];
|
||||
return (this.client360.projects || []).length
|
||||
? this.client360.projects
|
||||
: this.linkProjects.filter((p) => p.client_id === Number(this.view360ClientId));
|
||||
},
|
||||
|
||||
async loadClient360() {
|
||||
if (!this.view360ClientId) return;
|
||||
try {
|
||||
const data = await Cockpit.api('/clients/' + this.view360ClientId + '/360');
|
||||
if (!data.ok) throw new Error(data.error || '360 laden mislukt');
|
||||
this.client360 = data;
|
||||
this.linkProjects = data.projects || [];
|
||||
} catch (e) {
|
||||
Cockpit.toast('360: ' + (e.message || e), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async linkNasFile() {
|
||||
const path = (this.linkPathInput || '').trim();
|
||||
if (!path || !this.view360ClientId) return;
|
||||
try {
|
||||
await Cockpit.api('/documents/links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storage_path: path,
|
||||
client_id: Number(this.view360ClientId),
|
||||
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
|
||||
is_folder: false,
|
||||
}),
|
||||
});
|
||||
Cockpit.toast('Bestand gekoppeld', 'success');
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async linkNasFolder() {
|
||||
let path = (this.linkPathInput || '').trim();
|
||||
if (!path || !this.view360ClientId) return;
|
||||
if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
|
||||
try {
|
||||
await Cockpit.api('/documents/links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storage_path: path,
|
||||
client_id: Number(this.view360ClientId),
|
||||
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
|
||||
is_folder: true,
|
||||
}),
|
||||
});
|
||||
Cockpit.toast('Map gekoppeld: ' + path, 'success');
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async unlinkDocument(linkId) {
|
||||
try {
|
||||
await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async runAutoSync(force) {
|
||||
this.autoSyncBusy = true;
|
||||
try {
|
||||
const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
|
||||
const brain = (data.steps || {}).brain_sync || {};
|
||||
Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
|
||||
await this.refreshAutoSyncStatus();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
} finally {
|
||||
this.autoSyncBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAutoSyncStatus() {
|
||||
try {
|
||||
const data = await Cockpit.api('/brain/auto-sync/status');
|
||||
const last = data.last;
|
||||
if (last && last.created_at) {
|
||||
this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async quickStage(c) {
|
||||
if (!c || !c.id) return;
|
||||
const prev = c._prevStage;
|
||||
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');
|
||||
Cockpit.toast((c.name || 'Klant') + ' → ' + this.stageLabel(c.stage), 'success');
|
||||
if (this.detail?.client?.id === c.id) this.detail.client.stage = c.stage;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
} catch (e) {
|
||||
if (prev) c.stage = prev;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async searchStores() {
|
||||
stageSelectFocus(c) {
|
||||
c._prevStage = c.stage;
|
||||
},
|
||||
|
||||
async searchStores(resetPage) {
|
||||
if (resetPage) this.storePage = 1;
|
||||
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 params = this.buildStoreParams();
|
||||
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);
|
||||
this.storeTotal = r.total != null ? r.total : (r.count || this.storeResults.length);
|
||||
if (this.selectedStore && !this.storeResults.find(s => s.id === this.selectedStore.id)) {
|
||||
this.selectedStore = null;
|
||||
}
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
|
||||
buildStoreParams() {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(this.storePageSize),
|
||||
offset: String((this.storePage - 1) * this.storePageSize),
|
||||
sort: this.storeSort || '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);
|
||||
if (this.storeCity) params.set('city', this.storeCity);
|
||||
if (this.storePartnership) params.set('partnership', this.storePartnership);
|
||||
if (this.storePostcode) params.set('postcode_prefix', this.storePostcode);
|
||||
if (this.storeMinHalalOpp != null && this.storeMinHalalOpp !== '') params.set('min_halal_opportunity', String(this.storeMinHalalOpp));
|
||||
if (this.storeHalalGap) params.set('halal_gap_only', 'true');
|
||||
if (this.storeHalalCert) params.set('halal_certified', 'true');
|
||||
if (this.storeHalalSection) params.set('has_halal_section', 'true');
|
||||
if (this.storeLinkedCrm) params.set('linked_to_crm', 'true');
|
||||
if (this.storeHasPhone) params.set('has_phone', 'true');
|
||||
if (this.storeHasEmail) params.set('has_email', 'true');
|
||||
if (this.storeHasManager) params.set('has_manager', 'true');
|
||||
return params;
|
||||
},
|
||||
|
||||
resetStoreFilters() {
|
||||
this.storeQ = '';
|
||||
this.storeChain = '';
|
||||
this.storeProvince = '';
|
||||
this.storeCity = '';
|
||||
this.storePartnership = '';
|
||||
this.storeSort = 'name';
|
||||
this.storePostcode = '';
|
||||
this.storeMinHalalOpp = null;
|
||||
this.storeHalalGap = false;
|
||||
this.storeHalalCert = false;
|
||||
this.storeHalalSection = false;
|
||||
this.storeLinkedCrm = false;
|
||||
this.storeHasPhone = false;
|
||||
this.storeHasEmail = false;
|
||||
this.storeHasManager = false;
|
||||
this.storePage = 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
storePageCount() {
|
||||
return Math.max(1, Math.ceil((this.storeTotal || 0) / this.storePageSize));
|
||||
},
|
||||
|
||||
storeResultLabel() {
|
||||
const from = this.storeTotal ? (this.storePage - 1) * this.storePageSize + 1 : 0;
|
||||
const to = Math.min(this.storePage * this.storePageSize, this.storeTotal);
|
||||
return from + '–' + to + ' van ' + this.storeTotal + ' filialen';
|
||||
},
|
||||
|
||||
storePrevPage() {
|
||||
if (this.storePage <= 1) return;
|
||||
this.storePage -= 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
storeNextPage() {
|
||||
if (this.storePage >= this.storePageCount()) return;
|
||||
this.storePage += 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
async selectStore(id) {
|
||||
try {
|
||||
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
|
||||
@@ -493,7 +914,7 @@ function clientsHub() {
|
||||
}).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();
|
||||
await this.searchStores(false);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
@@ -530,7 +951,7 @@ function clientsHub() {
|
||||
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
|
||||
Cockpit.toast('Filiaal toegevoegd', 'success');
|
||||
this.storeCreateModal = false;
|
||||
await this.searchStores();
|
||||
await this.searchStores(false);
|
||||
if (r.item?.id) await this.selectStore(r.item.id);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
{% 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=17" />
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo" x-data="ctoDashboard()" x-init="init()">
|
||||
|
||||
<div class="herman-hero">
|
||||
<h1>CTO Dashboard <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">Platform · agents · infra · DevOps · goedkeuringen</p>
|
||||
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
|
||||
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
|
||||
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
|
||||
<a href="/agents?tab=approvals" class="beurs-mini-chip">✓ Goedkeuringen</a>
|
||||
<a href="/browser" class="beurs-mini-chip">🌐 Browser agent</a>
|
||||
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
|
||||
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
|
||||
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="beurs-mini-chip">📈 Grafana</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel herman-briefing">
|
||||
<header class="herman-briefing-header">
|
||||
<div>
|
||||
<h2>Platform status</h2>
|
||||
<div class="briefing-meta" x-text="opsMeta">Laden…</div>
|
||||
</div>
|
||||
<div class="herman-briefing-actions">
|
||||
<button type="button" class="btn btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
|
||||
<a href="/ops" class="btn btn-pulse-green">Volledige IT Ops →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="hm-neo-kpi-row" style="margin-bottom:1.25rem">
|
||||
<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>
|
||||
<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 ?? '—'"></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>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Open goedkeuringen</div>
|
||||
<div class="hm-neo-kpi-value" x-text="pendingApprovals"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Agent events (24u)</div>
|
||||
<div class="hm-neo-kpi-value" x-text="agentEvents24h"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Browser sessies (24u)</div>
|
||||
<div class="hm-neo-kpi-value">{{ kpis.browser_sessions_24h }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hm-dash-two-col">
|
||||
<section class="hm-dash-section" data-widget="approvals">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen</h3>
|
||||
<div class="panel" style="background:transparent;border:none;padding:0">
|
||||
<p class="page-tip">SysOps vraagt toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>.</p>
|
||||
<div id="cto-approvals-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Maintenance</h3>
|
||||
<template x-if="!maintenance.length">
|
||||
<p class="empty-state">Geen open maintenance items 🎉</p>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="n in maintenance.slice(0, 6)" :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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="page-tip" style="margin-top:0.75rem"><a href="/ops">Alle maintenance →</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="hub-grid hm-dash-feed" style="margin-top:1.25rem">
|
||||
<section class="panel">
|
||||
<h2>Agent feed <span class="live-badge" id="cto-feed-badge">Live</span></h2>
|
||||
<div id="cto-agent-feed">
|
||||
{% 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>Browser sessies</h2>
|
||||
{% if browser_sessions %}
|
||||
{% for s in browser_sessions[:6] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ s.created_at[11:16] if s.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-browser">browser</span>
|
||||
{{ s.title or s.url or s.task }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-state">Geen recente browser-sessies.</p>
|
||||
{% endif %}
|
||||
<p class="page-tip" style="margin-top:0.75rem"><a href="/browser">Browser agent →</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function ctoDashboard() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: {},
|
||||
meta: {},
|
||||
maintenance: [],
|
||||
opsMeta: 'Laden…',
|
||||
pendingApprovals: {{ kpis.pending_approvals }},
|
||||
agentEvents24h: 0,
|
||||
_pollStop: null,
|
||||
_wsStop: null,
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'cto');
|
||||
await this.refresh(false);
|
||||
await this.loadApprovals();
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refresh(false); this.loadApprovals(); }, 30000);
|
||||
this._wsStop = CockpitLive.connectFeed((data) => {
|
||||
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('cto-agent-feed'), data.events);
|
||||
CockpitLive.updateLiveBadge(document.getElementById('cto-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
||||
});
|
||||
},
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [status, maint, analytics] = await Promise.all([
|
||||
fetch('/api/ops/status').then(x => x.json()).catch(() => ({})),
|
||||
fetch('/api/ops/maintenance?limit=12').then(x => x.json()).catch(() => ({ items: [] })),
|
||||
fetch('/analytics/api/data').then(x => x.json()).catch(() => ({})),
|
||||
]);
|
||||
this.summary = status || { counts: {} };
|
||||
this.meta = (status.topology && status.topology.meta) || status.sources || {};
|
||||
this.maintenance = (maint.items || []).filter(n => !n.resolved);
|
||||
this.opsMeta = 'Sync: ' + new Date().toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam' });
|
||||
if (analytics.kpis) {
|
||||
this.pendingApprovals = analytics.kpis.pending_approvals ?? this.pendingApprovals;
|
||||
this.agentEvents24h = analytics.kpis.agent_events ?? 0;
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Platform data bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (toast !== false) Cockpit.toast(e.message || 'Fout', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
async loadApprovals() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
|
||||
const el = document.getElementById('cto-approvals-list');
|
||||
if (!el) return;
|
||||
const items = r.items || [];
|
||||
this.pendingApprovals = items.length;
|
||||
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><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+'">✓</button>' +
|
||||
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
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) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2,28 +2,38 @@
|
||||
{% 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=15" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
|
||||
{% 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 · trending retail · briefing · platform links</p>
|
||||
<h1>CEO Dashboard · Cucina <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">Business overzicht · retail · deals · briefing · marketing</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="/clients" class="beurs-mini-chip">👥 Klanten</a>
|
||||
<a href="/deals" class="beurs-mini-chip">💼 Deals</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>
|
||||
<a href="/retail" class="beurs-mini-chip">🛒 Retail</a>
|
||||
<a href="/analytics" class="beurs-mini-chip">📊 Analytics</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
|
||||
|
||||
<nav class="herman-dash-tabs" aria-label="Herman weergaven">
|
||||
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'overview' }" @click="setHermanTab('overview')">
|
||||
<span aria-hidden="true">📊</span> Overzicht
|
||||
</button>
|
||||
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'rss' }" @click="setHermanTab('rss')">
|
||||
<span aria-hidden="true">📰</span> RSS Feeds
|
||||
<span class="herman-dash-tab-badge" x-show="rssTotal > 0" x-text="rssTotal"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header class="herman-briefing-header">
|
||||
<div>
|
||||
<h2>Dagelijkse briefing</h2>
|
||||
@@ -60,7 +70,7 @@
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div id="dashboard-customize" class="hm-customize-wrap" x-show="editLayout" x-cloak></div>
|
||||
|
||||
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''">
|
||||
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
|
||||
|
||||
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · CEO KPI's</div>
|
||||
@@ -103,14 +113,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full" data-widget="rss">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · RSS feed</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> RSS live feed</h3>
|
||||
<div class="hm-chart-panel">
|
||||
<div id="briefing-rss-feed" class="hm-rss-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section" data-widget="retail">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
|
||||
<h3 class="hm-section-title">Retail operatie</h3>
|
||||
@@ -152,59 +154,115 @@
|
||||
<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 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" data-widget="feed">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · 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">
|
||||
<section class="panel hm-dash-full">
|
||||
<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>
|
||||
|
||||
<!-- ═══ RSS FEEDS TAB ═══ -->
|
||||
<div class="herman-rss-hub" x-show="hermanTab === 'rss'" x-cloak>
|
||||
<header class="herman-rss-head">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="page-tip">Alle live feeds op één plek — filter op categorie of zoek op titel</p>
|
||||
</div>
|
||||
<div class="herman-rss-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="refreshRss(true)" :disabled="rssLoading">↻ RSS ophalen</button>
|
||||
<a class="btn btn-sm" href="/marketing?tab=live">Marketing Hub</a>
|
||||
<a class="btn btn-sm" href="/marketing?tab=bookmarks">★ Bookmarks</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="herman-rss-filters">
|
||||
<input type="search" class="form-input" placeholder="Zoek in titels…" x-model="rssSearch" />
|
||||
<select class="form-input" x-model="rssCategory" @change="loadRssTab()">
|
||||
<option value="">Alle categorieën</option>
|
||||
<option value="kant-en-klaar">Kant-en-klaar</option>
|
||||
<option value="supermarkt">Supermarkt</option>
|
||||
<option value="food">Food</option>
|
||||
<option value="markt">Markt & M&A</option>
|
||||
<option value="regelgeving">Regelgeving</option>
|
||||
<option value="cbs">CBS</option>
|
||||
</select>
|
||||
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
|
||||
</div>
|
||||
|
||||
<div class="herman-rss-grid">
|
||||
<section class="herman-rss-col herman-rss-col-main">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
|
||||
<div class="herman-rss-list">
|
||||
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
|
||||
<article class="feed-card herman-rss-card">
|
||||
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
|
||||
<div class="feed-actions">
|
||||
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
|
||||
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
|
||||
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="herman-rss-col herman-rss-col-side">
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
|
||||
<h4>Regelgeving</h4>
|
||||
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
|
||||
<h4>CBS & data</h4>
|
||||
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="rssBookmarks.length">
|
||||
<h4>★ Opgeslagen</h4>
|
||||
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
|
||||
<small x-text="b.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=3"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=12"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=4"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=13"></script>
|
||||
<script>
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
function dashboardBriefing() {
|
||||
return {
|
||||
loading: false, loadingMsg: 'Herman werkt…', liveLog: [], liveProgress: '',
|
||||
editLayout: false,
|
||||
hermanTab: 'overview',
|
||||
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
|
||||
rssRegulations: {}, rssBookmarks: [],
|
||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
||||
_prefs: null, _setDragMode: null, _saveTimer: null,
|
||||
@@ -237,7 +295,51 @@ function dashboardBriefing() {
|
||||
this.editLayout = !this.editLayout;
|
||||
if (this._setDragMode) this._setDragMode(this.editLayout);
|
||||
},
|
||||
setHermanTab(tab) {
|
||||
this.hermanTab = tab;
|
||||
try {
|
||||
const url = new URL(location.href);
|
||||
if (tab === 'rss') url.searchParams.set('tab', 'rss');
|
||||
else url.searchParams.delete('tab');
|
||||
history.replaceState({}, '', url);
|
||||
} catch (e) {}
|
||||
if (tab === 'rss') this.loadRssTab();
|
||||
},
|
||||
filteredRss() {
|
||||
const q = (this.rssSearch || '').toLowerCase().trim();
|
||||
if (!q) return this.rssItems;
|
||||
return this.rssItems.filter(function (r) {
|
||||
return [r.title, r.feed_name, r.category, r.description].some(function (v) {
|
||||
return v && String(v).toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
},
|
||||
async loadRssTab() {
|
||||
this.rssLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (this.rssCategory) params.set('category', this.rssCategory);
|
||||
const [live, reg, bookmarks] = await Promise.all([
|
||||
fetch('/api/retail/rss/live?' + params).then(function (x) { return x.json(); }),
|
||||
fetch('/api/retail/regulations?limit=40').then(function (x) { return x.json(); }).catch(function () { return {}; }),
|
||||
fetch('/api/retail/rss/bookmarks?limit=50').then(function (x) { return x.json(); }).catch(function () { return { items: [] }; }),
|
||||
]);
|
||||
this.rssItems = live.items || [];
|
||||
this.rssTotal = live.count || this.rssItems.length;
|
||||
this.rssRegulations = reg || {};
|
||||
this.rssBookmarks = bookmarks.items || [];
|
||||
if (this._stats) {
|
||||
this._stats.rss_live = this.rssItems;
|
||||
this._stats.rss_items = this.rssTotal;
|
||||
}
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message || 'RSS laden mislukt', 'error');
|
||||
}
|
||||
this.rssLoading = false;
|
||||
},
|
||||
async init() {
|
||||
const qsTab = new URLSearchParams(location.search).get('tab');
|
||||
if (qsTab === 'rss') this.hermanTab = 'rss';
|
||||
window._dashboardVizMode = this._vizMode;
|
||||
const prefs = await DashboardLayout.loadPrefs();
|
||||
this._prefs = prefs;
|
||||
@@ -272,29 +374,23 @@ function dashboardBriefing() {
|
||||
const cnt = (this._stats && (this._stats.rss_items || (this._stats.rss_live || []).length)) || 0;
|
||||
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
|
||||
}
|
||||
await this.loadApprovals();
|
||||
await this.loadProjectsMini();
|
||||
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
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'}));
|
||||
});
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
|
||||
if (this.hermanTab === 'rss') await this.loadRssTab();
|
||||
},
|
||||
async refreshRss(toast) {
|
||||
if (toast) Cockpit.toast('RSS feeds ophalen…', 'info');
|
||||
try {
|
||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
|
||||
if (this._stats && live.items) {
|
||||
this._stats.rss_live = live.items;
|
||||
this._stats.rss_items = live.count || live.items.length;
|
||||
BriefingCharts.renderRssFeed(document.getElementById('briefing-rss-feed'), this._stats);
|
||||
await this.loadRssTab();
|
||||
if (this._stats) {
|
||||
BriefingCharts.renderRetail(document.getElementById('briefing-retail'), this._stats);
|
||||
BriefingCharts.renderFoodHighlights(document.getElementById('briefing-food-highlights'), this._stats);
|
||||
BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, window._dashboardKpis);
|
||||
}
|
||||
if (toast) Cockpit.toast('RSS bijgewerkt (' + (live.count || 0) + ' items)', 'success');
|
||||
if (toast) Cockpit.toast('RSS bijgewerkt (' + (this.rssTotal || 0) + ' items)', 'success');
|
||||
} catch (e) { if (toast) Cockpit.toast(e.message || 'RSS fout', 'error'); }
|
||||
},
|
||||
async loadApprovals() {
|
||||
@@ -363,6 +459,7 @@ function dashboardBriefing() {
|
||||
if (live.items && live.items.length) {
|
||||
this._stats.rss_live = live.items;
|
||||
this._stats.rss_items = live.count || live.items.length;
|
||||
this.rssTotal = this._stats.rss_items;
|
||||
}
|
||||
} catch (e) {}
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
|
||||
|
||||
+558
-446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
/* Export Intel — page layout */
|
||||
.ei-page { --ei-accent: #0ea5e9; }
|
||||
.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.ei-header h1 { font-size: 1.5rem; margin: 0; }
|
||||
.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
|
||||
.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
|
||||
.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
|
||||
.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
|
||||
.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
|
||||
.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
|
||||
|
||||
.ei-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.65rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
|
||||
border: 1px solid rgba(148,163,184,0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
|
||||
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
|
||||
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
|
||||
.ei-filter-label select,
|
||||
.ei-filter-label input {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148,163,184,0.22);
|
||||
background: #0a0e12;
|
||||
color: #e2e8f0;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.ei-filter-search-wrap input { width: 100%; min-width: 0; }
|
||||
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
||||
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
|
||||
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
|
||||
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
|
||||
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
|
||||
.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); min-height: 400px; background: #0a0e14; }
|
||||
.ei-map-wrap #ei-map { height: 100%; min-height: 400px; }
|
||||
|
||||
/* Leaflet popups — rich card */
|
||||
.ei-map-wrap .leaflet-popup-content-wrapper,
|
||||
.ei-map-wrap .leaflet-popup-tip {
|
||||
background: #0f1724;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
|
||||
}
|
||||
.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
|
||||
.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
|
||||
.ei-map-wrap .leaflet-popup-close-button {
|
||||
color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
|
||||
}
|
||||
.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
|
||||
|
||||
.ei-popup-card { padding: 0.85rem 1rem 1rem; }
|
||||
.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
|
||||
.ei-popup-badge {
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--badge-color, #38bdf8);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
padding: 0.2rem 0.5rem; border-radius: 6px;
|
||||
}
|
||||
.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
|
||||
.ei-map-wrap .leaflet-popup-content .ei-popup-name {
|
||||
display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
|
||||
color: #f8fafc !important; line-height: 1.3;
|
||||
}
|
||||
.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
|
||||
.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
|
||||
.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.ei-popup-v { color: #cbd5e1; word-break: break-word; }
|
||||
.ei-popup-v a { color: #38bdf8; text-decoration: none; }
|
||||
.ei-popup-v a:hover { text-decoration: underline; }
|
||||
.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
|
||||
.ei-popup-btn {
|
||||
width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||
background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
|
||||
}
|
||||
.ei-popup-btn:hover { filter: brightness(1.08); }
|
||||
|
||||
/* Map pins */
|
||||
.ei-pin-wrap { background: transparent !important; border: none !important; }
|
||||
.ei-pin {
|
||||
width: 30px; height: 30px; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ei-pin-ring {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
background: var(--pin-color);
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.45);
|
||||
}
|
||||
.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
|
||||
|
||||
/* Clusters */
|
||||
.ei-cluster-wrap { background: transparent !important; border: none !important; }
|
||||
.ei-cluster {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #0ea5e9, #0369a1);
|
||||
border: 2px solid rgba(255,255,255,0.55);
|
||||
box-shadow: 0 4px 16px rgba(14,165,233,0.45);
|
||||
color: #fff; font-weight: 800; font-size: 0.85rem;
|
||||
}
|
||||
.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
|
||||
.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
|
||||
.ei-map-wrap .marker-cluster-small,
|
||||
.ei-map-wrap .marker-cluster-medium,
|
||||
.ei-map-wrap .marker-cluster-large { background: transparent !important; }
|
||||
.ei-map-wrap .marker-cluster-small div,
|
||||
.ei-map-wrap .marker-cluster-medium div,
|
||||
.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
|
||||
|
||||
.ei-map-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
|
||||
}
|
||||
.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.ei-leg-pin { font-style: normal; }
|
||||
|
||||
.ei-map-empty { margin-bottom: 0.65rem; }
|
||||
.ei-map-empty-inner {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
font-size: 0.82rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
|
||||
.ei-map-empty-inner p { margin: 0.25rem 0; }
|
||||
.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
|
||||
|
||||
.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-table-wrap { overflow-x: auto; max-height: 480px; }
|
||||
.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||||
.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
|
||||
.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
|
||||
.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
|
||||
.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
|
||||
.ei-empty-cta { margin-top: 0.75rem; }
|
||||
.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
|
||||
.ei-type-distributor { color: #fbbf24; }
|
||||
.ei-type-wholesaler { color: #eab308; }
|
||||
.ei-type-contract_caterer { color: #a78bfa; }
|
||||
.ei-confidence { font-size: 0.65rem; color: #64748b; }
|
||||
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1100; }
|
||||
.ei-drawer { position: fixed; top: 0; right: 0; width: min(420px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1101; overflow-y: auto; }
|
||||
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
|
||||
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
|
||||
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
|
||||
.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
|
||||
.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
|
||||
.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
|
||||
.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
|
||||
.ei-drawer-meta strong { color: #e2e8f0; }
|
||||
.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
|
||||
.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
|
||||
.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
|
||||
.ei-drawer-kv span:first-child { color: #64748b; }
|
||||
.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
|
||||
.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
|
||||
.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
|
||||
.ei-contact-row a { color: #38bdf8; }
|
||||
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
|
||||
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
|
||||
@@ -0,0 +1,764 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Wereldexport · Foodlinkk B2B{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="/static/css/export-intel.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
<script src="/static/js/export-intel-map.js?v=8"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ei-page" x-data="exportIntelApp()" x-init="init()">
|
||||
<header class="ei-header page-header">
|
||||
<div>
|
||||
<h1>Wereldexport <span class="live-badge">Live</span></h1>
|
||||
<p class="ei-subtitle">Foodlinkk B2B · wereldwijde markten · halal vlees · cateraars · distributeurs</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse-green" @click="syncKind('world')" :disabled="busy" title="Europa · MENA · Afrika · Amerika">🌍 Sync wereld</button>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('region')" :disabled="busy || !region" title="Sync huidige regio-filter">Sync regio</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="syncKind('all')" :disabled="busy">⚡ Sync alles</button>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('distributors')" :disabled="busy">Distributeurs</button>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('caterers')" :disabled="busy">Cateraars</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="syncKind('contacts')" :disabled="busy">Contacten</button>
|
||||
<button class="btn btn-sm btn-pulse-purple" @click="syncKind('tenders')" :disabled="busy">TED</button>
|
||||
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="ei-sync-msg" x-show="syncMsg" x-text="syncMsg"></p>
|
||||
|
||||
<div class="ei-selection-bar" x-show="tab === 'map'" x-cloak>
|
||||
<div class="ei-selection-left">
|
||||
<span class="ei-selection-count" x-show="selectedIds.length" x-text="selectedIds.length + ' geselecteerd'"></span>
|
||||
<span class="ei-selection-hint" x-show="!selectedIds.length">Selecteer rijen met ☑ voor bulk-acties</span>
|
||||
</div>
|
||||
<div class="ei-selection-actions">
|
||||
<button type="button" class="btn btn-sm" @click="toggleSelectAllVisible()" title="Alles op lijst">☑ Alles</button>
|
||||
<button type="button" class="btn btn-sm" x-show="selectedIds.length" @click="clearSelection()">Wis selectie</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse" :disabled="!selectedIds.length || busy" @click="favoriteSelection()">★ Favoriet</button>
|
||||
<button type="button" class="btn btn-sm" :disabled="!selectedIds.length || busy" @click="unfavoriteSelection()">☆ Verwijder favoriet</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" :disabled="!selectedIds.length || crmPushBusy" @click="openCrmModal()">➕ Naar CRM</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ei-neo-kpi-row">
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#0ea5e9;--ring-pct:85"><div class="ei-neo-ring-inner">🌍</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Landen</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.territories||0"></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#38bdf8;--ring-pct:70"><div class="ei-neo-ring-inner">📊</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Entities</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.entities||0"></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#fbbf24;--ring-pct:75"><div class="ei-neo-ring-inner">📦</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Distributeurs</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.distributors||0"></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#a78bfa;--ring-pct:65"><div class="ei-neo-ring-inner">🏢</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Cateraars</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.caterers||0"></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#34d399;--ring-pct:60"><div class="ei-neo-ring-inner">📇</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Contacten</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.contacts||0"></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi">
|
||||
<div class="ei-neo-kpi-top">
|
||||
<div class="ei-neo-ring" style="--ring-color:#c084fc;--ring-pct:50"><div class="ei-neo-ring-inner">📋</div></div>
|
||||
<div><div class="ei-neo-kpi-label">Tenders open</div></div>
|
||||
</div>
|
||||
<div class="ei-neo-kpi-value" x-text="stats.tenders_open||0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ei-filter-bar">
|
||||
<div class="ei-filter-group ei-filter-group-geo">
|
||||
<label class="ei-filter-label">Regio
|
||||
<select x-model="region" @change="country=''; refresh()">
|
||||
<option value="">Alle regio's</option>
|
||||
<template x-for="r in regions" :key="r.code">
|
||||
<option :value="r.code" x-text="r.name_nl"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ei-filter-label">Land
|
||||
<select x-model="country" @change="onCountryChange()">
|
||||
<option value="">Alle landen</option>
|
||||
<template x-for="t in territories.filter(t => !region || t.region_code===region)" :key="t.code">
|
||||
<option :value="t.country_iso2" x-text="t.name_nl"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-search">
|
||||
<label class="ei-filter-label ei-filter-search-wrap">
|
||||
<span class="ei-filter-sr">Zoeken</span>
|
||||
<input type="search" placeholder="Organisatie, e-mail, stad…" x-model="q" @input="onSearchInput()" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-maptypes" x-show="showMapTypeFilter()" x-cloak>
|
||||
<span class="ei-filter-label">Kaart toont</span>
|
||||
<div class="ei-type-chips" role="group" aria-label="Types op kaart">
|
||||
<button type="button" class="ei-type-chip" :class="{ active: !mapTypesSelected.length }" @click="clearMapTypes()">Alle</button>
|
||||
<template x-for="chip in mapTypeChips" :key="chip.v">
|
||||
<button type="button" class="ei-type-chip" :class="{ active: isMapTypeActive(chip.v) }" @click="toggleMapType(chip.v)">
|
||||
<span x-text="chip.icon"></span> <span x-text="chip.l"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-halal" x-show="showMapTypeFilter()" x-cloak>
|
||||
<label class="ei-filter-check">
|
||||
<input type="checkbox" x-model="mapHalalMode" @change="applyMapFilters()" />
|
||||
<span>🥩 Halal-kansen modus</span>
|
||||
</label>
|
||||
<label class="ei-filter-label ei-halal-min" x-show="mapHalalMode">
|
||||
Min. score
|
||||
<input type="range" min="30" max="90" step="5" x-model.number="mapHalalMin" @change="applyMapFilters()" />
|
||||
<strong x-text="mapHalalMin"></strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-collection" x-show="tab === 'map'" x-cloak>
|
||||
<label class="ei-filter-check">
|
||||
<input type="checkbox" x-model="filterFavoritesOnly" @change="onFilterCollectionChange()" />
|
||||
<span>★ Alleen favorieten</span>
|
||||
</label>
|
||||
<label class="ei-filter-label">CRM
|
||||
<select x-model="filterCrmStatus" @change="onFilterCollectionChange()">
|
||||
<option value="">Alle</option>
|
||||
<option value="not_linked">Nog niet in CRM</option>
|
||||
<option value="linked">In CRM</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-context" x-show="showEntityTypeFilter() || showEmailFilter()" x-cloak>
|
||||
<label class="ei-filter-label" x-show="showEntityTypeFilter()">Type
|
||||
<select x-model="entityTypeFilter" @change="loadTabData()">
|
||||
<template x-for="o in entityTypeOptions()" :key="o.v">
|
||||
<option :value="o.v" x-text="o.l"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ei-filter-label" x-show="showEmailFilter()">E-mail
|
||||
<select x-model="filterHasEmail" @change="loadTabData()">
|
||||
<option value="">Alle</option>
|
||||
<option value="yes">Met e-mail</option>
|
||||
<option value="no">Zonder e-mail</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-meta">
|
||||
<span class="ei-filter-count" x-show="resultCount > 0" x-text="resultCount + ' resultaten'"></span>
|
||||
<span class="ei-filter-count ei-filter-fav" x-show="stats.favorites > 0" x-text="(stats.favorites||0) + ' ★'"></span>
|
||||
<button type="button" class="btn btn-sm ei-filter-clear" x-show="q || entityTypeFilter || mapTypesSelected.length || mapHalalMode || filterHasEmail || filterFavoritesOnly || filterCrmStatus" @click="clearFilters()">Wis filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vtabs-layout wereldexport-workspace" style="--vt-color:#0ea5e9" x-ref="vtabsWrap">
|
||||
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
|
||||
<div class="vtabs-nav-header"><strong>Modules</strong></div>
|
||||
<template x-for="t in tabs" :key="t.id">
|
||||
<button type="button" class="vtab-btn vtab-btn-rich" :class="{active: tab===t.id}" :data-tab="t.id"
|
||||
:style="'--tab-accent:'+t.color"
|
||||
@click="setTab(t.id, $el)">
|
||||
<span class="ei-vtab-icon-wrap" x-text="t.icon"></span>
|
||||
<span class="ei-vtab-text">
|
||||
<span class="ei-vtab-label" x-text="t.label"></span>
|
||||
<span class="ei-vtab-sub" x-show="tabSub(t.id)" x-text="tabSub(t.id)"></span>
|
||||
</span>
|
||||
<span class="ei-vtab-meta">
|
||||
<span class="ei-vtab-key" x-text="t.key"></span>
|
||||
<span class="ei-vtab-badge" x-show="tabCount(t.id) > 0" x-text="tabCount(t.id)"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="vtabs-content">
|
||||
<!-- Kaart -->
|
||||
<section x-show="tab==='map'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div>
|
||||
<h2>Wereldkaart</h2>
|
||||
<p>Klik pin voor preview · dubbelklik of “Volledig profiel” voor alle gegevens</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-map-legend">
|
||||
<template x-if="!mapHalalMode">
|
||||
<div class="ei-legend-row">
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#f59e0b">📦</i> Distributeur</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#eab308">🏪</i> Groothandel</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#a78bfa">🏢</i> Cateraar</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#34d399">🍽️</i> Horeca</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#0ea5e9">📍</i> Overig</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="mapHalalMode">
|
||||
<div class="ei-legend-row ei-legend-halal">
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#22c55e">●</i> Hoge kans (75+)</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#eab308">●</i> Gemiddeld (55+)</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#f97316">●</i> Matig (40+)</span>
|
||||
<span class="ei-legend-item"><i class="ei-leg-pin" style="--c:#64748b">●</i> Laag</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="ei-map-empty" x-show="tab==='map' && !entities.length && !busy" x-cloak>
|
||||
<div class="ei-map-empty-inner">
|
||||
<strong x-show="region || country">Nog geen data op de kaart voor deze selectie</strong>
|
||||
<strong x-show="!region && !country">Zoom in of filter op regio</strong>
|
||||
<p>Landen staan in het systeem, maar entities + contacten komen pas na een <em>OSM sync</em> (OpenStreetMap).</p>
|
||||
<p x-show="region==='africa'">Afrika: 51 landen toegevoegd — sync nog niet gedraaid voor dit continent.</p>
|
||||
<div class="ei-map-empty-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" x-show="region" @click="syncKind('region')" :disabled="busy">↻ Sync regio</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="syncKind('world')" :disabled="busy">🌍 Sync wereld</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-split">
|
||||
<div class="ei-map-wrap"><div id="ei-map"></div></div>
|
||||
<div class="ei-panel">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:0.9rem" x-text="mapHalalMode ? 'Top halal-kansen' : 'Entities op kaart'"></h3>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin:0 0 0.75rem" x-show="!mapHalalMode">Klik rij of kaart-pin voor volledig profiel.</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin:0 0 0.75rem" x-show="mapHalalMode">Klik een rij voor contact & context · kaart zoomt mee. Groen = beste halal-vlees kans.</p>
|
||||
|
||||
<div class="ei-list-focus" x-show="listFocusLoading || listFocusDetail" x-cloak>
|
||||
<div class="ei-list-focus-head">
|
||||
<strong x-show="listFocusLoading">Profiel laden…</strong>
|
||||
<template x-if="listFocusDetail">
|
||||
<div>
|
||||
<span class="ei-drawer-badge" :class="'ei-type-'+listFocusDetail.entity_type" x-text="typeLabel(listFocusDetail.entity_type)"></span>
|
||||
<span class="ei-halal-pill ei-halal-hot" x-show="listFocusDetail.halal_score" x-text="Math.round(listFocusDetail.halal_score)+'/100'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="btn btn-sm" @click="clearListFocus()">✕</button>
|
||||
</div>
|
||||
<template x-if="listFocusDetail">
|
||||
<div class="ei-list-focus-body">
|
||||
<h4 class="ei-list-focus-title" x-text="listFocusDetail.name"></h4>
|
||||
<p class="ei-list-focus-meta">
|
||||
<span x-text="territoryName(listFocusDetail.country_iso2)"></span>
|
||||
<span x-show="listFocusDetail.city"> · <span x-text="listFocusDetail.city"></span></span>
|
||||
<span> · Confidence <span x-text="listFocusDetail.confidence||0"></span>%</span>
|
||||
</p>
|
||||
<div class="ei-list-focus-kv" x-show="listFocusDetail.address_line">
|
||||
<span>Adres</span><span x-text="listFocusDetail.address_line"></span>
|
||||
</div>
|
||||
<div class="ei-list-focus-kv" x-show="listFocusDetail.email">
|
||||
<span>E-mail</span><a :href="'mailto:'+listFocusDetail.email" x-text="listFocusDetail.email"></a>
|
||||
</div>
|
||||
<div class="ei-list-focus-kv" x-show="listFocusDetail.phone">
|
||||
<span>Telefoon</span><a :href="'tel:'+listFocusDetail.phone" x-text="listFocusDetail.phone"></a>
|
||||
</div>
|
||||
<div class="ei-list-focus-kv" x-show="listFocusDetail.website">
|
||||
<span>Website</span><a :href="listFocusDetail.website" target="_blank" rel="noopener" x-text="listFocusDetail.website"></a>
|
||||
</div>
|
||||
<p class="ei-drawer-empty" x-show="!listFocusDetail.email && !listFocusDetail.phone && !listFocusDetail.website && !(listFocusDetail.contacts||[]).length">
|
||||
Geen contactgegevens — OSM heeft vaak alleen naam+locatie. Probeer ↻ Sync contacten.
|
||||
</p>
|
||||
<div class="ei-halal-tags" x-show="(listFocusDetail.halal_reason_labels||[]).length">
|
||||
<template x-for="tag in (listFocusDetail.halal_reason_labels||[])" :key="tag">
|
||||
<span class="ei-halal-tag" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-for="c in (listFocusDetail.contacts||[]).slice(0,3)" :key="c.id">
|
||||
<div class="ei-contact-row ei-contact-row-compact">
|
||||
<strong x-text="c.name || c.role || 'Contact'"></strong>
|
||||
<div x-show="c.email">📧 <a :href="'mailto:'+c.email" x-text="c.email"></a></div>
|
||||
<div x-show="c.phone">📞 <a :href="'tel:'+c.phone" x-text="c.phone"></a></div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ei-list-focus-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="openEntity(listFocusDetail.id)">Volledig profiel →</button>
|
||||
<a class="btn btn-sm" x-show="listFocusDetail.maps_url" :href="listFocusDetail.maps_url" target="_blank" rel="noopener">Google Maps</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="ei-halal-markets" x-show="mapHalalMode && halalTopMarkets.length">
|
||||
<h4>Landen met meeste kans</h4>
|
||||
<template x-for="m in halalTopMarkets.slice(0,8)" :key="m.country_iso2">
|
||||
<div class="ei-halal-market-row" @click="country=m.country_iso2; refresh()" style="cursor:pointer">
|
||||
<span class="ei-halal-flag" x-text="m.country_iso2"></span>
|
||||
<span class="ei-halal-market-name" x-text="m.country_iso2"></span>
|
||||
<strong class="ei-halal-score" x-text="Math.round(m.combined_score||0)"></strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ei-th-check"><input type="checkbox" @change="toggleSelectAllVisible()" title="Alles selecteren" /></th>
|
||||
<th class="ei-th-fav">★</th>
|
||||
<th>Naam</th>
|
||||
<th>Type</th>
|
||||
<th>Stad</th>
|
||||
<th>Contact</th>
|
||||
<th x-show="mapHalalMode">Score</th>
|
||||
<th>Land</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="e in (mapHalalMode && halalTopEntities.length ? halalTopEntities : entities)" :key="e.id">
|
||||
<tr
|
||||
@click="selectListEntity(e.id)"
|
||||
:class="{ 'ei-row-active': listFocusId === e.id, 'ei-row-selected': isSelected(e.id), 'ei-row-crm': e.client_id }"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
<td class="ei-td-check" @click.stop>
|
||||
<input type="checkbox" :checked="isSelected(e.id)" @change="toggleSelect(e.id, $event)" />
|
||||
</td>
|
||||
<td class="ei-td-fav" @click.stop>
|
||||
<button type="button" class="ei-fav-btn" :class="{ active: e.is_favorite }" @click="toggleFavorite(e.id, e.is_favorite, $event)" x-text="e.is_favorite ? '★' : '☆'"></button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="ei-row-name" x-text="e.name"></span>
|
||||
<span class="ei-crm-badge" x-show="e.client_id" title="In CRM">CRM</span>
|
||||
</td>
|
||||
<td><span class="ei-type-pill ei-type-pill-sm" :class="'ei-type-'+e.entity_type" x-text="typeLabel(e.entity_type)"></span></td>
|
||||
<td x-text="e.city || '—'"></td>
|
||||
<td class="ei-contact-cell" x-text="contactPreview(e)"></td>
|
||||
<td x-show="mapHalalMode"><span class="ei-halal-pill" :class="'ei-halal-'+(e.halal_score>=75?'hot':e.halal_score>=55?'warm':'mild')" x-text="Math.round(e.halal_score||0)"></span></td>
|
||||
<td x-text="e.country_iso2"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!entities.length">
|
||||
<p>Geen entities met geo in selectie.</p>
|
||||
<button class="btn btn-sm btn-pulse ei-empty-cta" @click="syncKind('distributors')">Start sync — fase B vult data</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Distributeurs -->
|
||||
<section x-show="tab==='distributors'" x-cloak x-effect="tab==='distributors' && loadTabData()">
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Distributeurs & groothandels</h2><p>OSM · Google Places · handmatige seeds</p></div>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('distributors')" :disabled="busy">↻ Sync</button>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Naam</th><th>Land</th><th>Stad</th><th>E-mail</th><th>Telefoon</th><th>Confidence</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="e in entities" :key="e.id">
|
||||
<tr @click="openEntity(e.id)" style="cursor:pointer">
|
||||
<td x-text="e.name"></td>
|
||||
<td x-text="e.country_iso2"></td>
|
||||
<td x-text="e.city||'—'"></td>
|
||||
<td x-text="e.primary_email||e.email||'—'"></td>
|
||||
<td x-text="e.primary_phone||e.phone||'—'"></td>
|
||||
<td class="ei-confidence" x-text="e.confidence+'%'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!entities.length">
|
||||
<p>Nog geen distributeurs in deze selectie.</p>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('distributors')">Sync distributeurs (fase B)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Eindklanten -->
|
||||
<section x-show="tab==='customers'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Eindklanten</h2><p>Restaurants · döner · slagers · horeca</p></div>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('customers')" :disabled="busy || !country">↻ Sync horeca</button>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Naam</th><th>Type</th><th>Land</th><th>Stad</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="e in entities" :key="e.id">
|
||||
<tr @click="openEntity(e.id)" style="cursor:pointer">
|
||||
<td x-text="e.name"></td>
|
||||
<td x-text="typeLabel(e.entity_type)"></td>
|
||||
<td x-text="e.country_iso2"></td>
|
||||
<td x-text="e.city||'—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!entities.length">
|
||||
<p>Selecteer een land en sync horeca via OSM.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cateraars -->
|
||||
<section x-show="tab==='caterers'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Facilitaire cateraars</h2><p>Albron · Sodexo · Compass · presence per land</p></div>
|
||||
<button class="btn btn-sm btn-pulse" @click="syncKind('caterers')" :disabled="busy">↻ Sync</button>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="margin-bottom:1rem">
|
||||
<h3 style="margin:0 0 0.5rem">Brand presence</h3>
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Brand</th><th>Land</th><th>Lokale naam</th><th>Takken</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="p in catererPresence" :key="p.id">
|
||||
<tr>
|
||||
<td x-text="p.brand_name"></td>
|
||||
<td x-text="p.country_iso2"></td>
|
||||
<td x-text="p.local_legal_name||'—'"></td>
|
||||
<td x-text="(p.service_lines||[]).join(', ')"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-panel">
|
||||
<h3 style="margin:0 0 0.5rem">Cateraar entities</h3>
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Naam</th><th>Land</th><th>E-mail</th><th>Website</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="e in entities" :key="e.id">
|
||||
<tr @click="openEntity(e.id)" style="cursor:pointer">
|
||||
<td x-text="e.name"></td>
|
||||
<td x-text="e.country_iso2"></td>
|
||||
<td x-text="e.primary_email||'—'"></td>
|
||||
<td><a :href="e.website" target="_blank" rel="noopener" x-text="e.website||'—'"></a></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contacten -->
|
||||
<section x-show="tab==='contacts'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Contactregistry</h2><p>E-mail · telefoon · outreach-ready</p></div>
|
||||
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Organisatie</th><th>Type</th><th>Land</th><th>Rol</th><th>Naam</th><th>E-mail</th><th>Telefoon</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="c in contacts" :key="c.id">
|
||||
<tr>
|
||||
<td x-text="c.entity_name"></td>
|
||||
<td x-text="typeLabel(c.entity_type)"></td>
|
||||
<td x-text="c.country_iso2"></td>
|
||||
<td x-text="c.role"></td>
|
||||
<td x-text="c.name||'—'"></td>
|
||||
<td x-text="c.email||'—'"></td>
|
||||
<td x-text="c.phone||c.mobile||'—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!contacts.length">
|
||||
<p>Contacten worden aangevuld in fase B (Places, websites, tenders).</p>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="syncKind('contacts')">Sync contacten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tenders -->
|
||||
<section x-show="tab==='tenders'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Tenders</h2><p>TED API · food · catering · meat</p></div>
|
||||
<button class="btn btn-sm btn-pulse-purple" @click="syncKind('tenders')" :disabled="busy">↻ TED sync</button>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Titel</th><th>Land</th><th>Status</th><th>Link</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="t in tenders" :key="t.id">
|
||||
<tr>
|
||||
<td x-text="t.title"></td>
|
||||
<td x-text="t.country_iso2||'—'"></td>
|
||||
<td x-text="t.status"></td>
|
||||
<td><a :href="t.url" target="_blank" rel="noopener">Open</a></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!tenders.length">
|
||||
<p>Geen tenders — klik TED sync.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Overheid -->
|
||||
<section x-show="tab==='gov'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Overheid & data</h2><p>Statistiek · tender portals per land</p></div>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Land</th><th>Naam</th><th>Categorie</th><th>Link</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="g in govSources" :key="g.id">
|
||||
<tr>
|
||||
<td x-text="g.country_iso2"></td>
|
||||
<td x-text="g.name"></td>
|
||||
<td x-text="g.category"></td>
|
||||
<td><a :href="g.url" target="_blank" rel="noopener">Open</a></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<section x-show="tab==='pipeline'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>CRM Pipeline</h2><p>Van Wereldexport naar klanten & deals</p></div>
|
||||
<div class="retail-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" :disabled="!selectedIds.length" @click="openCrmModal()">➕ Geselecteerde naar CRM</button>
|
||||
<a class="btn btn-sm btn-pulse" href="/clients">Klanten</a>
|
||||
<a class="btn btn-sm btn-pulse" href="/deals">Deals</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<p class="ei-pipeline-hint">Entities die je via <strong>Naar CRM</strong> hebt toegevoegd verschijnen hier met koppeling naar klant + deal.</p>
|
||||
<div class="ei-table-wrap" x-show="crmPipeline.length">
|
||||
<table class="ei-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisatie</th>
|
||||
<th>Type</th>
|
||||
<th>Land</th>
|
||||
<th>CRM klant</th>
|
||||
<th>Deal</th>
|
||||
<th>Toegevoegd</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="p in crmPipeline" :key="p.id">
|
||||
<tr @click="openEntity(p.id)" style="cursor:pointer">
|
||||
<td x-text="p.name"></td>
|
||||
<td x-text="typeLabel(p.entity_type)"></td>
|
||||
<td x-text="p.country_iso2"></td>
|
||||
<td>
|
||||
<a x-show="p.client_id" :href="'/clients'" @click.stop x-text="p.crm_client_name || ('#'+p.client_id)"></a>
|
||||
</td>
|
||||
<td x-text="p.crm_deal_title || '—'"></td>
|
||||
<td x-text="p.crm_pushed_at ? new Date(p.crm_pushed_at).toLocaleDateString('nl-NL') : '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!crmPipeline.length">
|
||||
<p>Nog geen entities in CRM vanuit Wereldexport.</p>
|
||||
<p>Selecteer leads op de kaart en klik <strong>➕ Naar CRM</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drawer — volledig profiel -->
|
||||
<div class="ei-drawer-root" x-show="drawerOpen" x-cloak>
|
||||
<div class="ei-drawer-backdrop" @click="closeDrawer()"></div>
|
||||
<aside class="ei-drawer ei-drawer-rich">
|
||||
<header class="ei-drawer-head">
|
||||
<button type="button" class="btn btn-sm" @click="closeDrawer()">✕ Sluiten</button>
|
||||
</header>
|
||||
<div class="ei-drawer-body" x-show="drawerLoading">
|
||||
<p class="ei-drawer-empty">Profiel laden…</p>
|
||||
</div>
|
||||
<template x-if="selected && !drawerLoading">
|
||||
<div class="ei-drawer-body">
|
||||
<div class="ei-drawer-hero">
|
||||
<span class="ei-drawer-badge" :class="'ei-type-'+selected.entity_type" x-text="typeLabel(selected.entity_type)"></span>
|
||||
<span class="ei-drawer-flag" x-text="selected.country_iso2"></span>
|
||||
<span class="ei-halal-pill ei-halal-hot" x-show="selected.halal_score" x-text="Math.round(selected.halal_score)+'/100'"></span>
|
||||
</div>
|
||||
<h2 class="ei-drawer-title" x-text="selected.name"></h2>
|
||||
<p class="ei-drawer-meta">
|
||||
<span x-text="territoryName(selected.country_iso2)"></span>
|
||||
· Confidence <strong x-text="(selected.confidence||0)+'%'"></strong>
|
||||
· Pipeline <strong x-text="selected.pipeline_stage||'—'"></strong>
|
||||
· Bron <strong x-text="selected.source||'—'"></strong>
|
||||
</p>
|
||||
|
||||
<div class="ei-halal-tags" x-show="(selected.halal_reason_labels||[]).length">
|
||||
<template x-for="tag in (selected.halal_reason_labels||[])" :key="tag">
|
||||
<span class="ei-halal-tag" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<section class="ei-drawer-section" x-show="selected.city || selected.address_line || (selected.lat && selected.lon)">
|
||||
<h4>Locatie</h4>
|
||||
<p x-show="selected.address_line" x-text="selected.address_line"></p>
|
||||
<p x-show="selected.city"><span x-text="selected.city"></span><span x-show="selected.postal_code">, </span><span x-text="selected.postal_code"></span></p>
|
||||
<p x-show="selected.lat && selected.lon" class="ei-drawer-coords">
|
||||
<span x-text="selected.lat.toFixed(5)+', '+selected.lon.toFixed(5)"></span>
|
||||
<a x-show="selected.maps_url" :href="selected.maps_url" target="_blank" rel="noopener">Open in Maps →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ei-drawer-section">
|
||||
<h4>Contact & web</h4>
|
||||
<div class="ei-drawer-kv" x-show="selected.email">
|
||||
<span>E-mail</span>
|
||||
<a :href="'mailto:'+selected.email" x-text="selected.email"></a>
|
||||
</div>
|
||||
<div class="ei-drawer-kv" x-show="selected.phone">
|
||||
<span>Telefoon</span>
|
||||
<a :href="'tel:'+selected.phone" x-text="selected.phone"></a>
|
||||
</div>
|
||||
<div class="ei-drawer-kv" x-show="selected.website">
|
||||
<span>Website</span>
|
||||
<a :href="selected.website" target="_blank" rel="noopener" x-text="selected.website"></a>
|
||||
</div>
|
||||
<p x-show="!selected.email && !selected.phone && !selected.website && !(selected.contacts||[]).length" class="ei-drawer-empty">
|
||||
Geen direct contact in OSM — klik <strong>Sync contacten</strong> om website/e-mail te verrijken.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ei-drawer-section" x-show="selected.halal_score != null || selected.volume_band || selected.cold_chain || selected.halal_cert_notes">
|
||||
<h4>Business & halal</h4>
|
||||
<div class="ei-drawer-kv" x-show="selected.halal_score != null">
|
||||
<span>Halal kans</span>
|
||||
<strong x-text="Math.round(selected.halal_score||0)+'/100'"></strong>
|
||||
</div>
|
||||
<div class="ei-drawer-kv" x-show="selected.volume_band">
|
||||
<span>Volume</span><span x-text="selected.volume_band"></span>
|
||||
</div>
|
||||
<div class="ei-drawer-kv" x-show="selected.cold_chain">
|
||||
<span>Cold chain</span><span>Ja</span>
|
||||
</div>
|
||||
<p x-show="selected.halal_cert_notes" x-text="selected.halal_cert_notes"></p>
|
||||
</section>
|
||||
|
||||
<section class="ei-drawer-section" x-show="selected.client_id">
|
||||
<h4>CRM koppeling</h4>
|
||||
<div class="ei-drawer-kv">
|
||||
<span>Klant</span>
|
||||
<a href="/clients" x-text="selected.crm_client_name || ('Klant #'+selected.client_id)"></a>
|
||||
</div>
|
||||
<div class="ei-drawer-kv" x-show="selected.crm_deal_title">
|
||||
<span>Deal</span><span x-text="selected.crm_deal_title"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ei-drawer-section">
|
||||
<h4>Contactpersonen (<span x-text="(selected.contacts||[]).length"></span>)</h4>
|
||||
<template x-for="c in (selected.contacts||[])" :key="c.id">
|
||||
<div class="ei-contact-row">
|
||||
<strong x-text="c.name || c.role || 'Contact'"></strong>
|
||||
<span class="ei-contact-role" x-show="c.role" x-text="c.role"></span>
|
||||
<span class="ei-contact-source" x-show="c.source" x-text="'Bron: '+c.source"></span>
|
||||
<div x-show="c.email">📧 <a :href="'mailto:'+c.email" x-text="c.email"></a></div>
|
||||
<div x-show="c.phone">📞 <a :href="'tel:'+c.phone" x-text="c.phone"></a></div>
|
||||
<div x-show="c.mobile">📱 <span x-text="c.mobile"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!(selected.contacts||[]).length" class="ei-drawer-empty">Geen contactpersonen in registry.</p>
|
||||
</section>
|
||||
|
||||
<div class="ei-drawer-actions">
|
||||
<button type="button" class="btn btn-sm" :class="selected.is_favorite ? 'btn-pulse' : ''" @click="toggleFavorite(selected.id, selected.is_favorite)">
|
||||
<span x-text="selected.is_favorite ? '★ Favoriet' : '☆ Favoriet maken'"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!selected.client_id" @click="pushEntityToCrm(selected.id)" :disabled="crmPushBusy">➕ Naar CRM</button>
|
||||
<a class="btn btn-sm btn-pulse" x-show="selected.client_id" href="/clients">Open klant in CRM</a>
|
||||
<a class="btn btn-sm" href="/deals" x-show="selected.deal_id">Open deal</a>
|
||||
<a class="btn btn-sm" href="/deals">Pipeline / Deals</a>
|
||||
<a class="btn btn-sm btn-pulse" :href="'/herman?context=export_catalog&entity='+selected.id">Catalogus via Herman</a>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="syncKind('contacts')" :disabled="busy">↻ Sync contacten</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- CRM bevestiging -->
|
||||
<div class="ei-modal-root" x-show="crmModalOpen" x-cloak>
|
||||
<div class="ei-drawer-backdrop" @click="crmModalOpen=false"></div>
|
||||
<div class="ei-modal-card">
|
||||
<h3>Toevoegen aan CRM</h3>
|
||||
<p><strong x-text="selectedIds.length"></strong> organisatie(s) worden klant in CRM.</p>
|
||||
<label class="ei-filter-check">
|
||||
<input type="checkbox" x-model="crmCreateDeals" />
|
||||
<span>Maak ook een deal per klant (aanbevolen)</span>
|
||||
</label>
|
||||
<p class="ei-modal-note">Bestaande klanten met hetzelfde e-mailadres worden gekoppeld i.p.v. dubbel aangemaakt.</p>
|
||||
<div class="ei-modal-actions">
|
||||
<button type="button" class="btn btn-sm" @click="crmModalOpen=false">Annuleren</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="pushToCrm()" :disabled="crmPushBusy">
|
||||
<span x-text="crmPushBusy ? 'Bezig…' : 'Bevestig → CRM'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/export-intel.js?v=10"></script>
|
||||
{% endblock %}
|
||||
@@ -1,103 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Herman · Browser{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/voice-live.css?v=2" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="hermanChat()" class="chat-layout">
|
||||
<header class="page-header">
|
||||
<h1>Herman</h1>
|
||||
<p class="subtitle page-tip">Co-CEO orchestrator — delegeert naar specialist agents (packaging, design, …).</p>
|
||||
</header>
|
||||
<div x-data="HermanAssistant.chatPage()" x-init="initChat()" class="chat-layout herman-browser-chat">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Herman</h1>
|
||||
<p class="subtitle page-tip">Praat of typ in de browser — voice, bevestiging, Export Intel popups.</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<a class="btn btn-sm btn-pulse" href="/voice">Voice Live</a>
|
||||
<a class="btn btn-sm" href="/export-intel">Wereldexport</a>
|
||||
</div>
|
||||
</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 1–3 minuten.</p>
|
||||
<form class="chat-input" @submit.prevent="generateImage">
|
||||
<input class="form-input" x-model="imagePrompt" placeholder="bijv. halal kipnuggets productfoto, studio licht" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="generating">
|
||||
<span x-show="!generating">Genereer</span>
|
||||
<span x-show="generating">Bezig…</span>
|
||||
</button>
|
||||
</form>
|
||||
<div x-show="generating" class="page-tip" style="margin-top:.5rem;">Ik ga even de gegevens ophalen — afbeelding wordt gemaakt…</div>
|
||||
<div x-show="lastImage" style="margin-top:1rem;">
|
||||
<img :src="lastImage" alt="Gegenereerde afbeelding" style="max-width:100%;border-radius:8px;border:1px solid var(--border);" />
|
||||
<div class="voice-confirm-bar" x-show="pendingAction" x-cloak>
|
||||
<div class="voice-confirm-text">
|
||||
<strong>Bevestiging nodig</strong>
|
||||
<span x-text="pendingAction ? ('Zoeken: ' + (pendingAction.entity_label || '') + ' in ' + (pendingAction.location_label || '')) : ''"></span>
|
||||
</div>
|
||||
<div class="voice-confirm-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="confirmPending()" :disabled="busy">Ja, zoeken</button>
|
||||
<button type="button" class="btn btn-sm" @click="cancelPending()" :disabled="busy">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" id="chat-log">
|
||||
{% if user_message %}<div class="chat-msg user"><p>{{ user_message }}</p></div>{% endif %}
|
||||
{% if last_reply %}<div class="chat-msg agent"><span class="badge">{{ last_agent }}</span><p>{{ last_reply }}</p></div>{% endif %}
|
||||
{% for h in history[:10] %}
|
||||
<div class="chat-msg agent"><small>{{ h.created_at }}</small><p>{{ h.title or h.body }}</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form class="chat-input" @submit.prevent="send">
|
||||
<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 class="card card-compact" style="margin-bottom:1rem;">
|
||||
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2>
|
||||
<form class="chat-input" @submit.prevent="generateImage()">
|
||||
<input class="form-input" x-model="imagePrompt" placeholder="bijv. halal kipnuggets productfoto" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="generating">Genereer</button>
|
||||
</form>
|
||||
<div x-show="lastImage" style="margin-top:1rem;">
|
||||
<img :src="lastImage" alt="Gegenereerd" style="max-width:100%;border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="voice-panel" style="min-height:420px">
|
||||
<div class="voice-chat-log" x-ref="chatLog" style="max-height:360px">
|
||||
<template x-for="(t, i) in turns" :key="i">
|
||||
<div class="voice-bubble" :class="t.role === 'user' ? 'voice-bubble-user' : 'voice-bubble-agent'">
|
||||
<div class="voice-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : ' · Herman')"></div>
|
||||
<template x-if="t.meta.agent_label">
|
||||
<span class="voice-bubble-badge" x-text="t.meta.agent_label + (formatDelegated(t.meta) ? ' → ' + formatDelegated(t.meta) : '')"></span>
|
||||
</template>
|
||||
<p x-text="t.text"></p>
|
||||
<img x-show="t.meta.image_url" :src="t.meta.image_url" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />
|
||||
</div>
|
||||
</template>
|
||||
{% for h in history[:6] %}
|
||||
<div class="voice-bubble voice-bubble-agent">
|
||||
<div class="voice-bubble-meta">{{ h.created_at }}</div>
|
||||
<p>{{ h.title or h.body }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="voice-live-preview" :class="{ 'is-interim': liveInterim }" x-show="livePreview || recording" style="margin:0.75rem 0">
|
||||
<strong x-text="recording ? 'Live preview' : 'Transcript'"></strong>
|
||||
<span x-text="livePreview || '…'"></span>
|
||||
</div>
|
||||
|
||||
<div class="voice-toggles" style="margin-bottom:0.75rem">
|
||||
<label><input type="checkbox" x-model="speakReply" /> Antwoord voorlezen</label>
|
||||
<label><input type="checkbox" x-model="handsFreeMode" /> Hands-free na antwoord</label>
|
||||
</div>
|
||||
|
||||
<div class="voice-manual-row">
|
||||
<button type="button" class="ha-mic voice-mic-inline" :class="micClass()"
|
||||
@mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()"
|
||||
@touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()"
|
||||
@click.prevent="onMicClick()" title="Microfoon">🎙️</button>
|
||||
<input class="form-input" x-model="manualText" placeholder="Typ of gebruik de microfoon…" @keydown.enter.prevent="sendText()" />
|
||||
<button class="btn btn-primary" @click="sendText()" :disabled="busy || !manualText.trim()">Send</button>
|
||||
</div>
|
||||
<p class="ha-status" x-show="statusDetail" x-text="statusDetail" style="margin-top:0.5rem"></p>
|
||||
</div>
|
||||
|
||||
<div class="ei-modal-root voice-results-modal" x-show="resultsOpen" x-cloak @click.self="closeResultsModal()">
|
||||
<div class="ei-modal-card voice-results-card" @click.stop>
|
||||
<h3 x-text="resultsTitle"></h3>
|
||||
<p><strong x-text="resultsTotal"></strong> resultaten</p>
|
||||
<div class="voice-results-table-wrap">
|
||||
<table class="voice-results-table">
|
||||
<thead><tr><th>Naam</th><th>Type</th><th>Stad</th><th>Contact</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(e, i) in resultsEntities" :key="i">
|
||||
<tr>
|
||||
<td><a :href="'/export-intel?open=' + e.id" x-text="e.name"></a></td>
|
||||
<td x-text="entityTypeLabel(e.entity_type)"></td>
|
||||
<td x-text="e.city || '—'"></td>
|
||||
<td x-text="e.primary_email || e.primary_phone || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-modal-actions">
|
||||
<a class="btn btn-sm" :href="resultsOpenUrl">Wereldexport</a>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="closeResultsModal()">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
function hermanChat() {
|
||||
return {
|
||||
message: '',
|
||||
imagePrompt: '',
|
||||
loading: false,
|
||||
generating: false,
|
||||
lastImage: '',
|
||||
async send() {
|
||||
if (!this.message.trim()) return;
|
||||
this.loading = true;
|
||||
const msg = this.message;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify({ message: msg }) });
|
||||
const log = document.getElementById('chat-log');
|
||||
log.innerHTML += '<div class="chat-msg user"><p>' + esc(msg) + '</p></div>';
|
||||
let badge = esc(r.agent_label || 'Herman');
|
||||
if (r.delegated_agents && r.delegated_agents.length > 1) {
|
||||
badge += ' · ' + esc(r.delegated_agents.join(', '));
|
||||
}
|
||||
let html = '<div class="chat-msg agent"><span class="badge">' + badge + '</span><p>' + esc(r.reply || '') + '</p>';
|
||||
if (r.image_url) {
|
||||
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 = '';
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
async generateImage() {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
this.generating = true;
|
||||
this.lastImage = '';
|
||||
try {
|
||||
const r = await Cockpit.api('/api/ai/generate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 })
|
||||
});
|
||||
if (r.proxy_url) {
|
||||
this.lastImage = r.proxy_url;
|
||||
const log = document.getElementById('chat-log');
|
||||
log.innerHTML += '<div class="chat-msg agent"><span class="badge">Design</span><p>Afbeelding klaar: ' + esc(this.imagePrompt) + '</p><img src="' + r.proxy_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" /></div>';
|
||||
}
|
||||
Cockpit.toast('Afbeelding gegenereerd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.generating = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/herman-assistant.js?v=1"></script>
|
||||
<style>.voice-mic-inline{width:44px;height:44px;border-radius:50%;font-size:1rem;flex-shrink:0}</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -53,15 +53,16 @@
|
||||
|
||||
<div class="vtabs-layout" style="--vt-color:#ff6b9d" x-ref="vtabsWrap">
|
||||
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
|
||||
<button type="button" class="vtab-btn" :class="tab==='live'?'active':''" @click="setTab('live')" data-vtab="live"><span class="vtab-icon">📡</span> Live Feed</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='reclame'?'active':''" @click="setTab('reclame')" data-vtab="reclame"><span class="vtab-icon">📁</span> Reclame folders</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='publish'?'active':''" @click="setTab('publish')" data-vtab="publish"><span class="vtab-icon">🚀</span> Automatisering</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='actie'?'active':''" @click="setTab('actie')" data-vtab="actie"><span class="vtab-icon">⚡</span> Actie</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='intel'?'active':''" @click="setTab('intel')" data-vtab="intel"><span class="vtab-icon">🎯</span> Intel</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='regelgeving'?'active':''" @click="setTab('regelgeving')" data-vtab="regelgeving"><span class="vtab-icon">⚖️</span> Regelgeving</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='bookmarks'?'active':''" @click="setTab('bookmarks')" data-vtab="bookmarks"><span class="vtab-icon">★</span> Opgeslagen RSS</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='social'?'active':''" @click="setTab('social')" data-vtab="social"><span class="vtab-icon">💬</span> Social</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='studio'?'active':''" @click="setTab('studio')" data-vtab="studio"><span class="vtab-icon">🎨</span> Studio</button>
|
||||
<div class="vtabs-nav-header"><strong>Marketing</strong></div>
|
||||
<button type="button" class="vtab-btn" :class="tab==='live'?'active':''" @click="setTab('live', $event.currentTarget)" data-vtab="live"><span class="vtab-icon">📡</span><span>Live Feed</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='reclame'?'active':''" @click="setTab('reclame', $event.currentTarget)" data-vtab="reclame"><span class="vtab-icon">📁</span><span>Reclame folders</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='publish'?'active':''" @click="setTab('publish', $event.currentTarget)" data-vtab="publish"><span class="vtab-icon">🚀</span><span>Automatisering</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='actie'?'active':''" @click="setTab('actie', $event.currentTarget)" data-vtab="actie"><span class="vtab-icon">⚡</span><span>Actie</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='intel'?'active':''" @click="setTab('intel', $event.currentTarget)" data-vtab="intel"><span class="vtab-icon">🎯</span><span>Marktscan</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='regelgeving'?'active':''" @click="setTab('regelgeving', $event.currentTarget)" data-vtab="regelgeving"><span class="vtab-icon">⚖️</span><span>Regelgeving</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='bookmarks'?'active':''" @click="setTab('bookmarks', $event.currentTarget)" data-vtab="bookmarks"><span class="vtab-icon">★</span><span>Opgeslagen RSS</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='social'?'active':''" @click="setTab('social', $event.currentTarget)" data-vtab="social"><span class="vtab-icon">💬</span><span>Social</span></button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='studio'?'active':''" @click="setTab('studio', $event.currentTarget)" data-vtab="studio"><span class="vtab-icon">🎨</span><span>Studio</span></button>
|
||||
</nav>
|
||||
<div class="vtabs-content">
|
||||
<section x-show="tab==='live'" class="panel">
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
|
||||
<header class="rc-header">
|
||||
<div>
|
||||
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
|
||||
<p class="rc-subtitle" x-show="meta.file_mtime">
|
||||
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
|
||||
· <span x-text="fmtDate(meta.file_mtime)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rc-header-actions">
|
||||
<div class="rc-view-toggle">
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
|
||||
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Cockpit: Excel-layout met charts -->
|
||||
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
|
||||
|
||||
<div class="rc-summary-strip">
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / mnd</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / jr</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Deals</span>
|
||||
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
|
||||
</div>
|
||||
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
|
||||
<div class="rc-style-dots">
|
||||
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
|
||||
<template x-for="s in styleFilters" :key="'d-' + s.id">
|
||||
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-wrap">
|
||||
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-donut" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
|
||||
<!-- Excel rij 1: goals -->
|
||||
<tr class="rc-visual-goals">
|
||||
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
|
||||
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-visual-head">
|
||||
<td class="rc-vcell">Deal</td>
|
||||
<td class="rc-vcell rc-num">Marge mnd</td>
|
||||
<td class="rc-vcell rc-num">Marge jr</td>
|
||||
<td class="rc-vcell rc-num">Aandeel</td>
|
||||
<td class="rc-vcell">Next steps</td>
|
||||
</tr>
|
||||
|
||||
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
|
||||
<tr class="rc-visual-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
|
||||
<td class="rc-vcell rc-vdeal">
|
||||
<span class="rc-deal-name" x-text="row.name"></span>
|
||||
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
|
||||
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_month">
|
||||
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_year">
|
||||
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdonut">
|
||||
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
|
||||
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vsteps">
|
||||
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
|
||||
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
|
||||
<td class="rc-expand-cell" colspan="5">
|
||||
<div class="rc-expand-inner">
|
||||
<div class="rc-expand-chart">
|
||||
<div class="rc-bar-chart">
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-stats">
|
||||
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
|
||||
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
|
||||
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-body">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
|
||||
<div class="rc-expand-task" x-show="row.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerken: originele Excel -->
|
||||
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
|
||||
<div class="rc-toolbar">
|
||||
<span class="rc-toolbar-title">Bewerken · DB</span>
|
||||
<div class="rc-toolbar-actions">
|
||||
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
|
||||
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
|
||||
<table class="rc-sheet" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-logo" />
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
<tr class="rc-row rc-row-goals">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.vision_text || ''"
|
||||
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.horizon_text || ''"
|
||||
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.mid_text || ''"
|
||||
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
|
||||
x-init="$el.textContent = goals.tagline || ''"
|
||||
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
|
||||
</tr>
|
||||
<tr class="rc-row rc-row-head">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-th">Deal</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Month</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Year</td>
|
||||
<td class="rc-cell rc-th">Next steps</td>
|
||||
</tr>
|
||||
<template x-for="row in sheetRows" :key="row.id || row.source_row">
|
||||
<tr class="rc-row rc-data-row"
|
||||
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
|
||||
@click="selectRow(row)">
|
||||
<template x-if="row.logoCell === 'cucina'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="row.logoCell === 'foodlinkk'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-foodlinkk">
|
||||
<span class="rc-fl-brand">Foodlinkk</span>
|
||||
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.name || ''"
|
||||
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_month)"
|
||||
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_year)"
|
||||
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.next_steps || ''"
|
||||
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
|
||||
<div class="rc-row-panel-head">
|
||||
<strong x-text="selectedRow?.name"></strong>
|
||||
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
|
||||
<option value="green">Groen (live)</option>
|
||||
<option value="red">Rood (inactief)</option>
|
||||
<option value="orange">Oranje</option>
|
||||
<option value="yellow">Geel (sectie)</option>
|
||||
<option value="blue">Blauw</option>
|
||||
<option value="white">Wit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rc-row-panel-body">
|
||||
<label>Agent taak
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
|
||||
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=5"></script>
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,7 @@
|
||||
<nav class="settings-tabs">
|
||||
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
|
||||
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
|
||||
<a href="/settings?tab=ai" class="settings-tab {% if active_tab == 'ai' %}active{% endif %}">AI / LLM</a>
|
||||
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
|
||||
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
|
||||
</nav>
|
||||
@@ -113,6 +114,109 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif active_tab == 'ai' %}
|
||||
<section class="panel settings-panel" x-data="llmSettingsPage()" x-init="load()">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>AI / LLM providers</h2>
|
||||
<p class="page-tip">Koppel cloud API's (DeepSeek, Gemini, Groq, OpenRouter) naast lokale Ollama. Standaard provider wordt gebruikt in chat, Herman en documenten.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuwe provider</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-active-banner" x-show="defaultProvider" x-cloak>
|
||||
<span class="badge badge-positive">Standaard</span>
|
||||
<span x-text="defaultProvider ? defaultProvider.label + ' · ' + defaultProvider.model : ''"></span>
|
||||
</div>
|
||||
|
||||
<div class="llm-preset-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.65rem;margin-bottom:1.25rem">
|
||||
<template x-for="pr in presets" :key="pr.id">
|
||||
<div class="perm-card" style="cursor:pointer" @click="quickAdd(pr)">
|
||||
<strong x-text="pr.label"></strong>
|
||||
<p class="page-tip" style="margin:0.35rem 0" x-text="pr.hint"></p>
|
||||
<small x-text="(pr.models||[]).slice(0,2).join(', ')"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="email-account-list">
|
||||
<template x-for="p in providers" :key="p.id">
|
||||
<article class="email-account-card" :class="{ 'is-active': p.is_default }">
|
||||
<div class="email-account-card-head">
|
||||
<div>
|
||||
<strong x-text="p.label"></strong>
|
||||
<span class="muted" x-text="p.preset_label + ' · ' + p.model"></span>
|
||||
</div>
|
||||
<span class="badge" :class="p.is_default ? 'badge-positive' : (p.is_active ? 'badge-neutral' : 'badge-muted')"
|
||||
x-text="p.is_default ? 'Standaard' : (p.is_active ? 'Actief' : 'Inactief')"></span>
|
||||
</div>
|
||||
<div class="email-account-meta">
|
||||
<span x-text="p.api_base_url || 'Ollama lokaal'"></span>
|
||||
<span x-text="p.api_key_set ? '🔒 API key opgeslagen' : (p.needs_key ? '⚠️ geen API key' : 'Geen key nodig')"></span>
|
||||
<span x-show="p.last_test_status" x-text="'Test: ' + p.last_test_status"></span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="edit(p)">Bewerken</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="activate(p.id)" x-show="!p.is_default">Maak standaard</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="testProvider(p.id)">Test</button>
|
||||
<button type="button" class="btn btn-reject btn-sm" @click="remove(p.id)" x-show="p.provider_type !== 'ollama' || providers.length > 1">Verwijder</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!providers.length && !loading">Nog geen LLM provider — voeg DeepSeek, Gemini of Groq toe.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
|
||||
<div class="modal-card settings-modal">
|
||||
<h3 x-text="form.id ? 'Provider bewerken' : 'Nieuwe LLM provider'"></h3>
|
||||
|
||||
<label class="form-label">Label
|
||||
<input class="form-input" x-model="form.label" placeholder="bv. DeepSeek productie" />
|
||||
</label>
|
||||
|
||||
<label class="form-label">Type
|
||||
<select class="form-input" x-model="form.provider_type" @change="applyPreset()">
|
||||
<template x-for="pr in presets" :key="'fp'+pr.id">
|
||||
<option :value="pr.id" x-text="pr.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-label">Model
|
||||
<input class="form-input" x-model="form.model" list="llm-model-list" placeholder="deepseek-chat" />
|
||||
<datalist id="llm-model-list">
|
||||
<template x-for="m in currentPresetModels()" :key="m">
|
||||
<option :value="m"></option>
|
||||
</template>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="form-label" x-show="formNeedsKey()">API base URL
|
||||
<input class="form-input" x-model="form.api_base_url" placeholder="https://api.deepseek.com/v1" />
|
||||
</label>
|
||||
|
||||
<label class="form-label" x-show="formNeedsKey()">API key
|
||||
<input class="form-input" type="password" x-model="form.api_key"
|
||||
:placeholder="form.api_key_set ? '•••••••• (laat leeg om te behouden)' : 'sk-...'" />
|
||||
</label>
|
||||
|
||||
<p class="page-tip" x-show="presetHint()" x-text="presetHint()"></p>
|
||||
<p class="page-tip" x-show="presetSignup()" >
|
||||
<a :href="presetSignup()" target="_blank" rel="noopener">→ Gratis API key aanmaken</a>
|
||||
</p>
|
||||
|
||||
<label class="form-check">
|
||||
<input type="checkbox" x-model="form.is_default" /> Instellen als standaard provider
|
||||
</label>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving">Opslaan</button>
|
||||
<button type="button" class="btn btn-secondary" @click="testForm()" :disabled="saving">Test</button>
|
||||
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% elif active_tab == 'permissions' %}
|
||||
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
|
||||
<div class="settings-panel-head">
|
||||
@@ -428,5 +532,145 @@ function defaultForm() {
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% elif active_tab == 'ai' %}
|
||||
<script>
|
||||
function llmDefaultForm() {
|
||||
return {
|
||||
id: null, label: '', provider_type: 'deepseek', api_base_url: '', api_key: '',
|
||||
model: 'deepseek-chat', is_active: true, is_default: false, api_key_set: false,
|
||||
};
|
||||
}
|
||||
|
||||
function llmSettingsPage() {
|
||||
return {
|
||||
providers: [],
|
||||
presets: [],
|
||||
defaultProvider: null,
|
||||
loading: true,
|
||||
formOpen: false,
|
||||
saving: false,
|
||||
form: llmDefaultForm(),
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/llm');
|
||||
this.providers = r.providers || [];
|
||||
this.presets = r.presets || [];
|
||||
this.defaultProvider = r.default || this.providers.find(p => p.is_default) || null;
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
openForm() {
|
||||
this.form = llmDefaultForm();
|
||||
this.applyPreset();
|
||||
this.formOpen = true;
|
||||
},
|
||||
|
||||
quickAdd(pr) {
|
||||
this.form = llmDefaultForm();
|
||||
this.form.provider_type = pr.id;
|
||||
this.form.label = pr.label;
|
||||
this.applyPreset();
|
||||
this.formOpen = true;
|
||||
},
|
||||
|
||||
edit(p) {
|
||||
this.form = Object.assign(llmDefaultForm(), p, { api_key: '' });
|
||||
this.formOpen = true;
|
||||
},
|
||||
|
||||
applyPreset() {
|
||||
const pr = this.presets.find(x => x.id === this.form.provider_type) || {};
|
||||
if (pr.api_base_url) this.form.api_base_url = pr.api_base_url;
|
||||
if ((pr.models || []).length && !this.form.id) this.form.model = pr.models[0];
|
||||
if (!this.form.label) this.form.label = pr.label || this.form.provider_type;
|
||||
},
|
||||
|
||||
currentPresetModels() {
|
||||
const pr = this.presets.find(x => x.id === this.form.provider_type);
|
||||
return (pr && pr.models) ? pr.models : [];
|
||||
},
|
||||
|
||||
formNeedsKey() {
|
||||
const pr = this.presets.find(x => x.id === this.form.provider_type);
|
||||
return pr ? !!pr.needs_key : true;
|
||||
},
|
||||
|
||||
presetHint() {
|
||||
const pr = this.presets.find(x => x.id === this.form.provider_type);
|
||||
return pr ? (pr.hint || '') : '';
|
||||
},
|
||||
|
||||
presetSignup() {
|
||||
const pr = this.presets.find(x => x.id === this.form.provider_type);
|
||||
return pr ? (pr.signup_url || '') : '';
|
||||
},
|
||||
|
||||
bodyFromForm() {
|
||||
const b = {
|
||||
label: this.form.label,
|
||||
provider_type: this.form.provider_type,
|
||||
api_base_url: this.form.api_base_url,
|
||||
model: this.form.model,
|
||||
is_active: true,
|
||||
is_default: this.form.is_default,
|
||||
extra_config: {},
|
||||
};
|
||||
if (this.form.api_key) b.api_key = this.form.api_key;
|
||||
return b;
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const body = this.bodyFromForm();
|
||||
if (this.form.id) {
|
||||
await Cockpit.api('/api/settings/llm/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await Cockpit.api('/api/settings/llm', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
Cockpit.toast('LLM provider opgeslagen', 'success');
|
||||
this.formOpen = false;
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async testForm() {
|
||||
if (!this.form.id) {
|
||||
Cockpit.toast('Sla eerst op, daarna testen', 'info');
|
||||
return;
|
||||
}
|
||||
await this.testProvider(this.form.id);
|
||||
},
|
||||
|
||||
async testProvider(id) {
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/llm/' + id + '/test', { method: 'POST' });
|
||||
Cockpit.toast(r.message || 'OK', 'success');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async activate(id) {
|
||||
try {
|
||||
await Cockpit.api('/api/settings/llm/' + id + '/activate', { method: 'POST' });
|
||||
Cockpit.toast('Standaard LLM bijgewerkt', 'success');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
if (!confirm('Deze LLM provider verwijderen?')) return;
|
||||
try {
|
||||
await Cockpit.api('/api/settings/llm/' + id, { method: 'DELETE' });
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+161
-56
@@ -1,63 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Voice Live · Herman{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/export-intel.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/voice-live.css?v=2" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="voicePage()">
|
||||
<header class="page-header">
|
||||
<h1>Voice · Whisper</h1>
|
||||
<p class="subtitle page-tip">Spreek of upload audio — Whisper transcribeert, Herman antwoordt. Ook via Telegram spraakbericht 🎤</p>
|
||||
</header>
|
||||
<section class="panel">
|
||||
<div class="browser-action-bar">
|
||||
<button type="button" class="btn btn-primary" @click="toggleRecord()" x-text="recording ? '⏹ Stop opname' : '🎤 Live opnemen'"></button>
|
||||
<input type="file" accept="audio/*" @change="onFile($event)" />
|
||||
<button class="btn btn-secondary" @click="transcribe()" :disabled="!file && !recordedBlob">Transcribeer</button>
|
||||
<button class="btn btn-primary" @click="sendHerman()" :disabled="!text">→ Herman</button>
|
||||
<div class="voice-live-page" x-data="VoiceLive.page()" x-init="init()" @beforeunload="destroy()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Voice Live <span class="live-badge">Realtime</span></h1>
|
||||
<p class="subtitle page-tip">Spreek met Herman — bevestiging bij zoekopdrachten, popup met Export Intel resultaten.</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button type="button" class="btn btn-sm" @click="clearSession()">Sessie wissen</button>
|
||||
<a class="btn btn-sm btn-pulse" href="/herman">Herman chat</a>
|
||||
<a class="btn btn-sm" href="/agents">Agents</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Bevestigingsbalk -->
|
||||
<div class="voice-confirm-bar" x-show="pendingAction" x-cloak>
|
||||
<div class="voice-confirm-text">
|
||||
<strong>Bevestiging nodig</strong>
|
||||
<span x-text="pendingAction ? (pendingAction.type === 'webbuilder_build' ? ('Agy website: ' + (pendingAction.project || '')) : ('Zoeken: ' + (pendingAction.entity_label || 'bedrijven') + ' in ' + (pendingAction.location_label || '…'))) : ''"></span>
|
||||
</div>
|
||||
<div class="voice-confirm-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="confirmPending()" :disabled="busy">Ja, zoeken</button>
|
||||
<button type="button" class="btn btn-sm" @click="cancelPending()" :disabled="busy">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="voice-live-grid">
|
||||
<section class="voice-panel voice-mic-zone">
|
||||
<button
|
||||
type="button"
|
||||
class="voice-mic-btn"
|
||||
:class="micClass()"
|
||||
@mousedown.prevent="onMicDown()"
|
||||
@mouseup.prevent="onMicUp()"
|
||||
@mouseleave="recording && !handsFreeMode && onMicUp()"
|
||||
@touchstart.prevent="onMicDown()"
|
||||
@touchend.prevent="onMicUp()"
|
||||
@click.prevent="onMicClick()"
|
||||
title="PTT · hands-free toggle · Spatiebalk"
|
||||
>🎙️</button>
|
||||
<div class="voice-status" x-text="status === 'listening' ? (handsFreeMode ? '● HANDS-FREE' : '● LIVE') : (busy ? '⏳ Verwerken…' : 'Gereed')"></div>
|
||||
<p class="voice-status-sub" x-text="statusDetail"></p>
|
||||
|
||||
<div class="voice-live-preview" :class="{ 'is-interim': liveInterim }">
|
||||
<strong x-text="recording ? 'Live preview (browser)' : 'Whisper transcript'"></strong>
|
||||
<span x-text="livePreview || 'Je spraak verschijnt hier terwijl je praat…'"></span>
|
||||
</div>
|
||||
|
||||
<div class="voice-toggles">
|
||||
<label>
|
||||
<input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" />
|
||||
<span>Hands-free (auto-luisteren na antwoord)</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" x-model="conversationMode" />
|
||||
<span>Gespreksmodus (auto → Herman na opname)</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" x-model="speakReply" />
|
||||
<span>Antwoord voorlezen (browser TTS)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="voice-manual-row">
|
||||
<input class="form-input" type="text" placeholder="Of typ: ja / nee / opdracht…" x-model="manualText" @keydown.enter.prevent="sendManual()" />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="sendManual()" :disabled="busy || !manualText.trim()">→</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="voice-panel">
|
||||
<div class="voice-chat-head">
|
||||
<h2>Gesprek met Herman</h2>
|
||||
<span class="ei-filter-count" x-text="turns.length + ' berichten'"></span>
|
||||
</div>
|
||||
<div class="voice-chat-log" id="voice-chat-log">
|
||||
<template x-for="(t, i) in turns" :key="i">
|
||||
<div class="voice-bubble" :class="t.role === 'user' ? 'voice-bubble-user' : 'voice-bubble-agent'">
|
||||
<div class="voice-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : ' · Herman')"></div>
|
||||
<template x-if="t.role === 'agent' && t.meta.agent_label">
|
||||
<span class="voice-bubble-badge" x-text="t.meta.agent_label + (formatDelegated(t.meta) ? ' → ' + formatDelegated(t.meta) : '')"></span>
|
||||
</template>
|
||||
<p x-text="t.text"></p>
|
||||
<p class="voice-bubble-meta" x-show="t.meta.routing_reason" x-text="t.meta.routing_reason"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!turns.length" class="page-tip" style="margin:1rem 0">Vraag bv. “zoek alle distributeurs in Dubai” — Herman vraagt eerst om bevestiging.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="voice-panel">
|
||||
<h3 class="voice-side-head">Achter de schermen</h3>
|
||||
<ul class="voice-pipeline">
|
||||
<template x-for="(s, i) in pipeline" :key="i">
|
||||
<li :class="{ 'is-running': s.status === 'running', 'is-error': s.status === 'error' }">
|
||||
<span class="vl-phase" x-text="s.phase"></span>
|
||||
<span class="vl-label" x-text="s.label"></span>
|
||||
<span class="vl-detail" x-show="s.detail" x-text="s.detail"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<p x-show="!pipeline.length" class="page-tip" style="font-size:0.72rem">Pipeline-stappen per beurt.</p>
|
||||
|
||||
<h3 class="voice-side-head" style="margin-top:0.75rem">Live agent-feed</h3>
|
||||
<div class="voice-feed">
|
||||
<template x-for="(f, i) in feed" :key="i">
|
||||
<div class="voice-feed-item">
|
||||
<span class="vl-ts" x-text="f.at"></span>
|
||||
<strong x-text="f.agent"></strong>
|
||||
<span x-text="f.message"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Export Intel resultaten popup -->
|
||||
<div class="ei-modal-root voice-results-modal" x-show="resultsOpen" x-cloak @click.self="closeResultsModal()" @keydown.escape.window="closeResultsModal()">
|
||||
<div class="ei-modal-card voice-results-card" @click.stop>
|
||||
<h3 x-text="resultsTitle"></h3>
|
||||
<p><strong x-text="resultsTotal"></strong> resultaten · Export Intel</p>
|
||||
<div class="voice-results-table-wrap">
|
||||
<table class="voice-results-table">
|
||||
<thead>
|
||||
<tr><th>Naam</th><th>Type</th><th>Stad</th><th>Contact</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(e, i) in resultsEntities" :key="i">
|
||||
<tr>
|
||||
<td><a :href="'/export-intel?tab=distributors&open=' + e.id" x-text="e.name"></a></td>
|
||||
<td x-text="entityTypeLabel(e.entity_type)"></td>
|
||||
<td x-text="e.city || '—'"></td>
|
||||
<td x-text="e.primary_email || e.primary_phone || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p x-show="!resultsEntities.length" class="page-tip">Geen resultaten in deze selectie.</p>
|
||||
</div>
|
||||
<div class="ei-modal-actions">
|
||||
<a class="btn btn-sm" :href="resultsOpenUrl" target="_blank">Open in Wereldexport</a>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="closeResultsModal()">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ei-modal-root voice-results-modal" x-show="webbuilderOpen" x-cloak @click.self="closeWebbuilderModal()">
|
||||
<div class="ei-modal-card voice-results-card" @click.stop>
|
||||
<h3 x-text="webbuilderTitle"></h3>
|
||||
<p><strong x-text="webbuilderProject"></strong> · Agy / Antigravity</p>
|
||||
<p class="page-tip">Preview: <a :href="webbuilderPreview" target="_blank" rel="noopener" x-text="webbuilderPreview"></a></p>
|
||||
<div class="ei-modal-actions">
|
||||
<a class="btn btn-sm btn-pulse" :href="webbuilderAgentsUrl">Agents terminal</a>
|
||||
<a class="btn btn-sm" :href="webbuilderPreview" target="_blank" rel="noopener">Open preview</a>
|
||||
<button type="button" class="btn btn-sm" @click="closeWebbuilderModal()">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="form-input" rows="6" x-model="text" placeholder="Transcript verschijnt hier…"></textarea>
|
||||
<pre class="browser-preview-text" x-show="reply" x-text="reply"></pre>
|
||||
</section>
|
||||
<ul>{% for ev in voice_events %}<li>{{ ev.title }} — {{ ev.created_at }}</li>{% else %}<li class="empty-state">Nog geen voice events.</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function voicePage() {
|
||||
return {
|
||||
file: null, text: '', reply: '', recording: false, recordedBlob: null, mediaRecorder: null, chunks: [],
|
||||
onFile(e) { this.file = e.target.files[0]; this.recordedBlob = null; },
|
||||
async toggleRecord() {
|
||||
if (this.recording && this.mediaRecorder) {
|
||||
this.mediaRecorder.stop(); this.recording = false; return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.chunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
this.mediaRecorder.ondataavailable = e => this.chunks.push(e.data);
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.recordedBlob = new Blob(this.chunks, { type: 'audio/webm' });
|
||||
this.file = new File([this.recordedBlob], 'live.webm', { type: 'audio/webm' });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
Cockpit.toast('Opname klaar — klik Transcribeer', 'success');
|
||||
};
|
||||
this.mediaRecorder.start(); this.recording = true;
|
||||
} catch (e) { Cockpit.toast('Microfoon geweigerd: ' + e.message, 'error'); }
|
||||
},
|
||||
async transcribe() {
|
||||
const f = this.file;
|
||||
if (!f) return;
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
const r = await fetch('/api/voice/transcribe', { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || 'Transcribe failed');
|
||||
this.text = j.text || j.transcript || '';
|
||||
Cockpit.toast('Whisper klaar', 'success');
|
||||
},
|
||||
async sendHerman() {
|
||||
const r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify({ message: this.text }) });
|
||||
this.reply = r.reply || JSON.stringify(r);
|
||||
Cockpit.toast('Herman antwoordde', 'success');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/voice-live.js?v=3"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user