SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC

This commit is contained in:
sysops
2026-06-23 10:04:23 +00:00
parent 3bf15c4850
commit 26fe76afdd
165 changed files with 47427 additions and 1264 deletions
+1
View File
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
};
function cfg(key, fallbackColor) {
+756 -83
View File
@@ -1,9 +1,149 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
const TIER_ROWS = [4, 5, 7];
const MIN_NODE_GAP = 148;
const VIEW_W = 1200;
const VIEW_H = 860;
function make(tag, attrs = {}) {
/** Korte rol-context per agent (NL) */
const AGENT_HINTS = {
marketing: { short: 'Campagnes', domain: 'Groei' },
retail: { short: 'Supermarkten', domain: 'Groei' },
bizdev: { short: 'Deals', domain: 'Groei' },
research: { short: 'Marktintel', domain: 'Groei' },
browser: { short: 'Web research', domain: 'Groei' },
webbuilder: { short: 'Websites', domain: 'Product' },
product: { short: 'Assortiment', domain: 'Product' },
packaging: { short: 'Verpakking', domain: 'Product' },
design: { short: 'Visuals', domain: 'Product' },
halal: { short: 'Compliance', domain: 'Product' },
sourcing: { short: 'Inkoop', domain: 'Product' },
finance: { short: 'Cijfers', domain: 'Operatie' },
hr: { short: 'Team', domain: 'Operatie' },
knowledge: { short: 'Kennisbank', domain: 'Operatie' },
email: { short: 'Inbox', domain: 'Operatie' },
sysops: { short: 'Infra', domain: 'Operatie' },
};
const CLUSTERS = {
growth: {
label: 'Groei',
icon: '📈',
color: 'rgba(0, 229, 255, 0.14)',
stroke: 'rgba(0, 229, 255, 0.35)',
agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
},
product: {
label: 'Product',
icon: '📦',
color: 'rgba(168, 85, 247, 0.12)',
stroke: 'rgba(168, 85, 247, 0.35)',
agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
},
ops: {
label: 'Operatie',
icon: '⚙️',
color: 'rgba(34, 197, 94, 0.1)',
stroke: 'rgba(34, 197, 94, 0.32)',
agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
},
};
/** @type {Record<string, object>} */
const COMM_MODES = {
pyramid: {
id: 'pyramid',
label: 'Piramide',
subtitle: 'Herman als hub',
icon: '◆',
desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.42,
},
mesh: {
id: 'mesh',
label: 'Vol netwerk',
subtitle: 'Alle partners',
icon: '⬡',
desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.35,
edgeBend: 0.38,
},
org: {
id: 'org',
label: 'Organigram',
subtitle: 'Pure hiërarchie',
icon: '▤',
desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: false,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.12,
straightExec: true,
},
pulse: {
id: 'pulse',
label: 'Live stream',
subtitle: 'Alleen actief',
icon: '◎',
desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: true,
edgeBend: 0.45,
},
clusters: {
id: 'clusters',
label: 'Domeinen',
subtitle: 'Per afdeling',
icon: '◉',
desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
layout: 'clusters',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.2,
edgeBend: 0.5,
intraClusterOnly: false,
},
};
const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
let cachedData = null;
let pickerBound = false;
let onModeChangeCb = null;
let lastMountOpts = {};
function make(tag, attrs) {
attrs = attrs || {};
const el = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
Object.entries(attrs).forEach(function (pair) {
el.setAttribute(pair[0], String(pair[1]));
});
return el;
}
@@ -11,9 +151,234 @@
while (el.firstChild) el.removeChild(el.firstChild);
}
function linePath(x1, y1, x2, y2) {
const midY = y1 + (y2 - y1) * 0.45;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
function curvePath(x1, y1, x2, y2, bend) {
if (bend != null && bend < 0.18) {
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
}
const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
}
function clusterOf(key) {
const k = (key || '').toLowerCase();
return (
Object.keys(CLUSTERS).find(function (cid) {
return CLUSTERS[cid].agents.indexOf(k) >= 0;
}) || null
);
}
function spreadTier(nodes, width, minGap) {
if (nodes.length <= 1) return;
nodes.sort(function (a, b) {
return a._x - b._x;
});
for (let i = 1; i < nodes.length; i++) {
const gap = nodes[i]._x - nodes[i - 1]._x;
if (gap < minGap) {
const shift = minGap - gap;
for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
}
}
const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
const offset = width / 2 - tierCx;
nodes.forEach(function (n) {
n._x += offset;
});
}
function deoverlapLayout(souls, width) {
const tiers = {};
souls.forEach(function (n) {
const y = n._y || 0;
if (!tiers[y]) tiers[y] = [];
tiers[y].push(n);
});
Object.keys(tiers).forEach(function (y) {
spreadTier(tiers[y], width, MIN_NODE_GAP);
});
}
function agentSubtitle(node) {
const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
const task = (node.current_task || node.last_event_title || '').trim();
if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
if (hint) return hint.short;
return (node.role_title || '').split('·')[0].trim().slice(0, 24);
}
function agentStatus(node) {
if (node.is_active) return 'live';
const h = node.health || 'idle';
if (h === 'warn') return 'warn';
if (h === 'healthy') return 'ready';
return 'idle';
}
function addRotateAnim(group, dur) {
const anim = document.createElementNS(NS, 'animateTransform');
anim.setAttribute('attributeName', 'transform');
anim.setAttribute('type', 'rotate');
anim.setAttribute('from', '0 0 0');
anim.setAttribute('to', '360 0 0');
anim.setAttribute('dur', dur || '12s');
anim.setAttribute('repeatCount', 'indefinite');
group.appendChild(anim);
}
function layoutPyramid(souls, width, positions) {
const cx = width / 2;
const hermanY = positions.hermanY;
const execY = positions.execY;
const tierYs = positions.tierYs;
const minGap = MIN_NODE_GAP;
let idx = 0;
TIER_ROWS.forEach(function (count, tierIdx) {
const y = tierYs[tierIdx] || 580;
const slice = souls.slice(idx, idx + count);
idx += count;
const n = slice.length;
if (!n) return;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
slice.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._tier = tierIdx;
node._cluster = clusterOf(node.agent_key);
});
});
while (idx < souls.length) {
const y = 660;
const rest = souls.slice(idx);
const n = rest.length;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
rest.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._cluster = clusterOf(node.agent_key);
});
idx = souls.length;
}
deoverlapLayout(souls, width);
return { cx: cx, hermanY: hermanY, execY: execY };
}
function layoutClusters(souls, width, height) {
const cx = width / 2;
const hermanY = height * 0.42;
const execY = 58;
const clusterCenters = {
growth: { x: cx - 280, y: height * 0.72, r: 118 },
product: { x: cx, y: height * 0.78, r: 118 },
ops: { x: cx + 280, y: height * 0.72, r: 118 },
};
souls.forEach(function (node) {
const key = (node.agent_key || '').toLowerCase();
const cid = clusterOf(key) || 'product';
const center = clusterCenters[cid];
const members = CLUSTERS[cid].agents;
const idx = members.indexOf(key);
const n = members.length;
const angleStart = -Math.PI * 0.72;
const angleEnd = -Math.PI * 0.28;
const angle =
n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
const dist = center.r * 0.72;
node._x = center.x + Math.cos(angle) * dist;
node._y = center.y + Math.sin(angle) * dist * 0.55;
node._cluster = cid;
node._clusterCx = center.x;
node._clusterCy = center.y;
});
return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
}
function isSvgVisible(svg) {
if (!svg) return false;
const r = svg.getBoundingClientRect();
return r.width > 8 && r.height > 8;
}
function showMeshLoading(svg, text) {
clear(svg);
const g = make('g', { class: 'mesh-loading' });
const t = make('text', {
x: 600,
y: 360,
'text-anchor': 'middle',
fill: '#94a3b8',
'font-size': 14,
});
t.textContent = text || 'Netwerk laden…';
g.appendChild(t);
svg.appendChild(g);
}
function showMeshError(svg, text) {
clear(svg);
const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
t.textContent = text || 'Mesh kon niet geladen worden';
svg.appendChild(t);
}
function whenSvgVisible(svg, fn) {
if (!svg) return;
if (isSvgVisible(svg)) {
fn();
return;
}
let tries = 0;
const tick = function () {
tries += 1;
if (isSvgVisible(svg)) {
fn();
return;
}
if (tries < 60) {
requestAnimationFrame(tick);
} else {
fn();
}
};
requestAnimationFrame(tick);
}
function watchSvgResize(svg, renderFn) {
if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
if (!wrap) return;
let hadSize = isSvgVisible(svg);
svg._meshResizeObs = new ResizeObserver(function () {
const vis = isSvgVisible(svg);
if (vis && !hadSize) {
hadSize = true;
renderFn();
} else if (vis) {
hadSize = true;
}
});
svg._meshResizeObs.observe(wrap);
}
function renderMesh(svg, opts) {
if (!svg || !cachedData) return;
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
commMode: (opts && opts.commMode) || getSavedMode(),
});
const doDraw = function () {
try {
drawMesh(svg, cachedData, drawOpts);
} catch (e) {
console.error('drawMesh failed', e);
showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
}
};
doDraw();
watchSvgResize(svg, doDraw);
}
async function loadMeshData() {
@@ -22,67 +387,209 @@
return r.json();
}
function drawMesh(svg, data) {
async function ensureMeshData() {
if (cachedData) return cachedData;
cachedData = await loadMeshData();
return cachedData;
}
let pendingRender = false;
function scheduleRender(opts) {
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return;
if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
if (pendingRender) return;
pendingRender = true;
whenSvgVisible(svg, function () {
pendingRender = false;
renderMesh(svg, opts);
});
}
function nodePos(nodesByKey, key) {
const n = nodesByKey[key];
if (n && n._x != null) return { x: n._x, y: n._y };
return null;
}
function resolveMode(opts) {
const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] || COMM_MODES.pyramid;
}
function drawMesh(svg, data, opts) {
opts = opts || {};
const mode = resolveMode(opts);
const onNodeClick = opts.onNodeClick;
clear(svg);
svg.setAttribute('data-mesh-mode', mode.id);
const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1100;
const height = vb && vb.height ? vb.height : 640;
const width = vb && vb.width ? vb.width : VIEW_W;
const height = vb && vb.height ? vb.height : VIEW_H;
if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
}
const cx = width / 2;
const hermanY = 280;
const execY = 75;
const agentY = 520;
const positions = {
execY: 72,
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
tierYs: [400, 540, 690],
};
const souls = (data.nodes || []).filter((n) => {
const souls = (data.nodes || []).filter(function (n) {
const key = (n.agent_key || '').toLowerCase();
return key && key !== 'herman';
});
const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
const layoutMeta =
mode.layout === 'clusters'
? layoutClusters(souls, width, height)
: layoutPyramid(souls, width, positions);
const agentCount = Math.max(1, souls.length);
const pad = 70;
const span = width - pad * 2;
souls.forEach((node, i) => {
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
if (agentCount === 1) node._x = cx;
node._y = agentY;
const cx = layoutMeta.cx;
const hermanY = layoutMeta.hermanY;
const execY = layoutMeta.execY;
const nodesByKey = {};
souls.forEach(function (n) {
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
});
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
const edgesLayer = make('g');
const nodesLayer = make('g');
const bgLayer = make('g', { class: 'mesh-bg' });
const clusterLayer = make('g', { class: 'mesh-clusters' });
const edgesLayer = make('g', { class: 'mesh-edges' });
const nodesLayer = make('g', { class: 'mesh-nodes' });
svg.appendChild(bgLayer);
if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
function addEdge(x1, y1, x2, y2, pulse) {
const d = linePath(x1, y1, x2, y2);
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
if (mode.layout === 'pyramid') {
positions.tierYs.forEach(function (y, i) {
bgLayer.appendChild(
make('rect', {
x: 40,
y: y - 42,
width: width - 80,
height: 84,
rx: 12,
class: 'mesh-tier-band mesh-tier-' + i,
})
);
});
}
if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
Object.keys(CLUSTERS).forEach(function (cid) {
const c = CLUSTERS[cid];
const center = layoutMeta.clusterCenters[cid];
if (!center) return;
const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
g.appendChild(
make('ellipse', {
cx: center.x,
cy: center.y,
rx: center.r + 28,
ry: center.r * 0.62,
fill: c.color,
stroke: c.stroke,
'stroke-width': 1.2,
})
);
const lbl = make('text', {
x: center.x,
y: center.y - center.r * 0.35,
class: 'mesh-cluster-label',
'text-anchor': 'middle',
});
lbl.textContent = c.icon + ' ' + c.label;
g.appendChild(lbl);
clusterLayer.appendChild(g);
});
}
function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
const attrs = { d: d, class: 'mesh-edge ' + cls };
if (extraAttrs) Object.assign(attrs, extraAttrs);
edgesLayer.appendChild(make('path', attrs));
if (pulse) {
const p = make('path', { d, class: 'mesh-pulse' });
p.style.animationDuration = pulse + 's';
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
edgesLayer.appendChild(p);
}
}
souls.forEach((node) => {
const weight = edgesBySource[node.agent_key]?.weight || 1;
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
if (mode.showExecutive) {
const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
}
(data.peer_edges || []).forEach(function (e) {
const live = e.type === 'live';
if (live && !mode.showLivePeers) return;
if (!live && !mode.showStaticPeers) return;
if (mode.activeOnly && !live) return;
const a = nodePos(nodesByKey, e.source);
const b = nodePos(nodesByKey, e.target);
if (!a || !b) return;
const srcNode = nodesByKey[e.source];
const tgtNode = nodesByKey[e.target];
const sameCluster =
srcNode &&
tgtNode &&
srcNode._cluster &&
srcNode._cluster === tgtNode._cluster;
if (mode.intraClusterOnly && !sameCluster) return;
const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
const pulse = live ? 2.2 : false;
const extra = {};
if (!live && mode.staticPeerOpacity != null) {
extra['stroke-opacity'] = mode.staticPeerOpacity;
}
if (sameCluster && mode.layout === 'clusters') {
extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
}
addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
});
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
if (mode.showReport) {
(data.report_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const a = nodePos(nodesByKey, e.source);
if (!a) return;
addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
});
}
if (mode.showDelegate) {
(data.delegate_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const b = nodePos(nodesByKey, e.target);
if (!b) return;
addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
});
}
function drawExec(node, cls) {
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
g.lastChild.textContent = node.label;
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
t.textContent = node.label;
g.appendChild(t);
const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
sub.textContent = node.sub;
g.appendChild(sub);
nodesLayer.appendChild(g);
@@ -91,56 +598,222 @@
drawExec(ceo, 'mesh-ceo');
drawExec(cto, 'mesh-cto');
const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
crown.textContent = '👑';
herman.appendChild(crown);
const hLabel = make('text', { x: cx, y: hermanY + 18 });
hLabel.textContent = 'Herman · Co-CEO';
herman.appendChild(hLabel);
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
hSub.textContent = 'Takenverdeler';
herman.appendChild(hSub);
nodesLayer.appendChild(herman);
const hermanActive =
(data.delegate_edges || []).some(function (e) {
return e.active;
}) ||
(data.report_edges || []).some(function (e) {
return e.active;
});
souls.forEach((node) => {
const hermanG = make('g', {
class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''),
transform: 'translate(' + cx + ',' + hermanY + ')',
});
if (hermanActive) {
const ringG = make('g', { class: 'mesh-herman-ring' });
ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 }));
addRotateAnim(ringG, '14s');
hermanG.appendChild(ringG);
}
hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 }));
const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' });
crown.textContent = '👑';
hermanG.appendChild(crown);
const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' });
hLabel.textContent = 'Herman';
hermanG.appendChild(hLabel);
const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' });
hSub.textContent = 'Co-CEO · takenverdeler';
hermanG.appendChild(hSub);
const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' });
const hermanNode = (data.nodes || []).find(function (n) {
return (n.agent_key || '').toLowerCase() === 'herman';
});
hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents';
hermanG.appendChild(hTask);
nodesLayer.appendChild(hermanG);
souls.forEach(function (node, idx) {
const key = (node.agent_key || '').toLowerCase();
const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName;
const live = !!node.is_active;
const status = agentStatus(node);
const hint = AGENT_HINTS[key];
const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : '';
const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1;
const group = make('g', {
class:
'mesh-node mesh-agent mesh-status-' +
status +
' ' +
(node.health || 'idle') +
(live ? ' mesh-agent-live' : '') +
clusterCls,
transform: 'translate(' + node._x + ',' + node._y + ')',
});
if (live) {
const pulseG = make('g', { class: 'mesh-pulse-wrap' });
pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' }));
group.appendChild(pulseG);
}
group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' }));
const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' });
emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●';
group.appendChild(emoji);
const displayName = (node.display_name || key || '?').split(' ')[0];
const nameY = labelAbove ? -30 : 36;
const ctxY = labelAbove ? -16 : 52;
const domY = labelAbove ? -2 : 66;
const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' });
t.textContent = displayName;
group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
group.appendChild(role);
const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
status.textContent = (node.health || 'idle').toUpperCase();
group.appendChild(status);
group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) {
Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
}
const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
ctx.textContent = agentSubtitle(node);
group.appendChild(ctx);
if (hint && mode.layout !== 'clusters') {
const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
dom.textContent = hint.domain;
group.appendChild(dom);
}
const tip =
(node.role_title || '') +
(node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
(node.current_task ? '\nNu: ' + node.current_task : '');
group.setAttribute('title', tip.slice(0, 200));
group.addEventListener('click', function () {
if (onNodeClick) onNodeClick(node);
});
nodesLayer.appendChild(group);
});
}
async function mount(targetId) {
const svg = document.getElementById(targetId);
if (!svg) return;
try {
const data = await loadMeshData();
drawMesh(svg, data);
} catch (e) {
clear(svg);
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
msg.textContent = 'Mesh kon niet geladen worden';
svg.appendChild(msg);
function updatePickerUI(modeId) {
document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
const active = btn.getAttribute('data-mesh-mode') === modeId;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
const wrap = document.querySelector('.mesh-wrap');
if (wrap) {
wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
wrap.setAttribute('data-mode', modeId);
}
const badge = document.getElementById('mesh-mode-badge');
if (badge && COMM_MODES[modeId]) {
badge.textContent = COMM_MODES[modeId].label;
}
}
window.AgentsMesh = { mount };
function pickMode(modeId, opts, silent) {
const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
if (!silent) saveMode(mode);
updatePickerUI(mode);
if (onModeChangeCb) onModeChangeCb(mode);
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return mode;
showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
ensureMeshData()
.then(function () {
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
})
.catch(function (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
});
return mode;
}
function bindModePicker(opts) {
lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
if (pickerBound) return;
pickerBound = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-mesh-mode]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = btn.getAttribute('data-mesh-mode');
pickMode(id);
});
}
function setModeChangeCallback(fn) {
onModeChangeCb = fn;
}
async function mount(targetId, opts) {
opts = opts || {};
lastMountOpts = opts;
const svg = document.getElementById(targetId);
if (!svg) return null;
bindModePicker(opts);
const modeObj = resolveMode(opts);
updatePickerUI(modeObj.id);
showMeshLoading(svg, 'Netwerk laden…');
try {
await ensureMeshData();
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
return cachedData;
} catch (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
return null;
}
}
async function refresh(targetId, opts) {
cachedData = null;
return mount(targetId, opts);
}
function redraw(targetId, opts) {
const svg = document.getElementById(targetId);
if (!svg || !cachedData) return;
whenSvgVisible(svg, function () {
renderMesh(svg, opts || {});
});
}
function saveMode(modeId) {
if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
}
function getSavedMode() {
const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] ? id : 'pyramid';
}
window.AgentsMesh = {
mount,
refresh,
redraw,
pickMode,
bindModePicker,
setModeChangeCallback,
loadMeshData,
drawMesh,
COMM_MODES,
CLUSTERS,
saveMode,
getSavedMode,
};
function bootPicker() {
bindModePicker({});
const saved = getSavedMode();
updatePickerUI(saved);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootPicker);
} else {
bootPicker();
}
if (window.location.pathname.indexOf('/agents') >= 0) {
ensureMeshData().catch(function () {});
}
})();
+244
View File
@@ -0,0 +1,244 @@
(function () {
const buffers = {};
let liveEs = null;
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function nowIso() {
return new Date().toISOString();
}
function lineClass(type) {
if (!type) return 'line-action';
if (type === 'command') return 'line-command';
if (type === 'output') return 'line-output';
if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
if (type === 'error') return 'line-error';
if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
if (String(type).indexOf('browse') >= 0) return 'line-action';
return 'line-action';
}
function formatLine(entry) {
const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
const msg = entry.message || entry.title || '';
const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
return (
'<div class="term-line ' +
lineClass(entry.type) +
'"><span class="term-ts">' +
esc(t) +
'</span><span class="term-msg">' +
esc(msg + detail) +
'</span></div>'
);
}
function render(key) {
const els = document.querySelectorAll('[data-term-key="' + key + '"]');
if (!els.length) return;
const lines = buffers[key] || [];
const html =
!lines.length
? '<div class="term-line term-idle"><span class="term-msg">Typ <code>help</code> voor commando\'s · Enter om uit te voeren</span></div>'
: lines.slice(-80).map(formatLine).join('');
els.forEach(function (el) {
el.innerHTML = html;
el.scrollTop = el.scrollHeight;
const win = el.closest('.agent-term-window');
if (win && lines.length) win.classList.add('is-live');
});
}
function pushLine(key, entry, skipDup) {
if (!key || !entry) return;
key = String(key).toLowerCase();
if (!buffers[key]) buffers[key] = [];
if (skipDup && entry._id) {
const seen = buffers[key].some(function (l) {
return l._id === entry._id;
});
if (seen) return;
}
if (!entry.at) entry.at = nowIso();
buffers[key].push(entry);
if (buffers[key].length > 120) buffers[key].shift();
render(key);
}
function setBusy(key, busy) {
document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
input.disabled = !!busy;
input.classList.toggle('is-busy', !!busy);
});
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
const win = body.closest('.agent-term-window');
if (win) win.classList.toggle('is-running', !!busy);
});
}
async function runCommand(key, cmd) {
key = String(key || '').toLowerCase();
cmd = (cmd || '').trim();
if (!cmd) return;
if (cmd.toLowerCase() === 'clear') {
buffers[key] = [];
render(key);
return;
}
pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
setBusy(key, true);
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd }),
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
const err =
(typeof data.detail === 'string' && data.detail) ||
(Array.isArray(data.detail) && data.detail[0]?.msg) ||
'Commando mislukt';
pushLine(key, { type: 'error', message: err, at: nowIso() });
return;
}
(data.lines || []).forEach(function (line) {
pushLine(key, {
type: line.type || 'output',
message: line.message || '',
detail: line.detail || '',
at: nowIso(),
});
});
} catch (e) {
pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
} finally {
setBusy(key, false);
}
}
function bindInputs() {
document.querySelectorAll('[data-term-input]').forEach(function (input) {
if (input._termBound) return;
input._termBound = true;
const key = input.getAttribute('data-term-input');
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const val = input.value;
input.value = '';
runCommand(key, val);
}
});
input.addEventListener('click', function (e) {
e.stopPropagation();
});
});
}
async function loadHistory(key) {
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
if (!r.ok) return;
const data = await r.json();
buffers[key] = (data.items || []).map(function (ev) {
const meta = ev.metadata || {};
let type = 'action';
const et = ev.event_type || '';
if (et.indexOf('handoff') >= 0) type = 'handoff_out';
if (meta.target_agent) type = 'handoff_out';
if (meta.source_agent) type = 'handoff_in';
if (et.indexOf('monitor') >= 0) type = 'monitor';
if (et.indexOf('browse') >= 0) type = 'browse';
if (et === 'terminal_in') type = 'command';
if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
return {
_id: ev.id,
type: type,
message: ev.title || ev.event_type,
detail: ev.body,
at: ev.created_at,
};
});
render(key);
} catch (e) {}
}
function handleLivePayload(d) {
if (!d || d.type === 'connected' || d.type === 'error') return;
const key = (d.agent || '').toLowerCase();
if (!key) return;
pushLine(
key,
{
_id: d.id,
type: d.type,
message: d.message,
detail: d.detail,
at: d.at,
},
true
);
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
const win = el.closest('.agent-term-window');
if (win) win.classList.add('is-live');
});
}
function startLiveStream() {
if (liveEs || typeof EventSource === 'undefined') return;
liveEs = new EventSource('/api/agents/live/stream');
liveEs.onmessage = function (ev) {
try {
handleLivePayload(JSON.parse(ev.data));
} catch (e) {}
};
liveEs.onerror = function () {
/* EventSource auto-reconnects */
};
}
function stopLiveStream() {
if (liveEs) {
try {
liveEs.close();
} catch (e) {}
liveEs = null;
}
}
async function mountAll() {
const nodes = document.querySelectorAll('[data-term-key]');
const keys = [];
nodes.forEach(function (n) {
const k = n.getAttribute('data-term-key');
if (k && keys.indexOf(k) < 0) keys.push(k);
});
await Promise.all(keys.map(loadHistory));
bindInputs();
startLiveStream();
}
function stopAll() {
stopLiveStream();
}
window.AgentsTerminals = {
mountAll,
stopAll,
pushLine,
render,
runCommand,
bindInputs,
startLiveStream,
stopLiveStream,
};
})();
File diff suppressed because one or more lines are too long
+12 -31
View File
@@ -174,19 +174,15 @@ window.BriefingCharts = (function () {
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
var items = stats.food_market_highlights || stats.rss_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen highlights — <a href="/marketing">RSS ophalen</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = (r.category || 'markt').toUpperCase();
return '<div class="hm-highlight-item"><span class="hm-cat-badge">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
@@ -332,17 +328,7 @@ window.BriefingCharts = (function () {
function renderRetail(container, stats) {
if (!container) return;
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var when = (r.published_at || '').substring(0, 10);
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
@@ -369,19 +355,15 @@ window.BriefingCharts = (function () {
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5);
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (trending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
trending.forEach(function (r) {
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
});
html += '</ul>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
@@ -423,7 +405,6 @@ window.BriefingCharts = (function () {
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
+3 -4
View File
@@ -3,7 +3,6 @@
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
rss: { title: 'RSS live feed', icon: '📰' },
retail: { title: 'Retail operatie', icon: '🏪' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
@@ -17,9 +16,9 @@
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/?tab=rss' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/?tab=rss' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing?tab=bookmarks' },
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
+66
View File
@@ -0,0 +1,66 @@
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
(function () {
var DEFAULT = 'overview';
function radioId(tab) {
return 'doctab-' + tab;
}
function getActiveTab() {
var checked = document.querySelector('.doc-tab-input:checked');
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
}
function show(tab) {
if (!tab) tab = DEFAULT;
var radio = document.getElementById(radioId(tab));
if (radio) radio.checked = true;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
window.__docActiveTab = tab;
}
function syncAlpine(tab) {
var hub = document.querySelector('.doc-hub');
if (!hub || !window.Alpine) return;
try {
var d = Alpine.$data(hub);
if (!d) return;
if (d.activeTab !== tab) d.activeTab = tab;
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
} catch (e) {
console.warn('doc-tabs alpine', e);
}
}
function onTabChange() {
var tab = getActiveTab();
window.__docActiveTab = tab;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
syncAlpine(tab);
}
function bind() {
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
if (radio.dataset.bound) return;
radio.dataset.bound = '1';
radio.addEventListener('change', onTabChange);
});
}
window.DocTabs = { show: show, getActive: getActiveTab };
bind();
document.addEventListener('DOMContentLoaded', function () {
bind();
show(window.__docActiveTab || getActiveTab());
});
document.addEventListener('alpine:initialized', function () {
bind();
show(window.__docActiveTab || getActiveTab());
syncAlpine(getActiveTab());
});
})();
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
/**
* Export Intel map — Leaflet + clustering + rich popups
*/
window.ExportIntelMap = (function () {
var map = null;
var clusterLayer = null;
var onEntityClick = null;
var halalMode = false;
var markersById = {};
var HALAL_COLORS = {
hot: '#22c55e',
warm: '#eab308',
mild: '#f97316',
low: '#64748b',
};
var TYPE_CFG = {
distributor: { color: '#f59e0b', icon: '📦', label: 'Distributeur' },
wholesaler: { color: '#eab308', icon: '🏪', label: 'Groothandel' },
importer: { color: '#f97316', icon: '🚢', label: 'Importeur' },
logistics: { color: '#38bdf8', icon: '🚚', label: 'Logistiek' },
contract_caterer:{ color: '#a78bfa', icon: '🏢', label: 'Cateraar' },
restaurant: { color: '#34d399', icon: '🍽️', label: 'Restaurant' },
doner_shoarma: { color: '#4ade80', icon: '🥙', label: 'Döner / shoarma' },
butcher: { color: '#f87171', icon: '🥩', label: 'Slager' },
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
};
function cfg(type) {
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function row(label, val, link) {
if (!val) return '';
var inner = link
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
: escapeHtml(val);
return (
'<div class="ei-popup-row">' +
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
'<span class="ei-popup-v">' + inner + '</span>' +
'</div>'
);
}
function halalColor(p) {
var tier = p.halal_tier || 'low';
return HALAL_COLORS[tier] || HALAL_COLORS.low;
}
function popupHtml(p) {
var c = cfg(p.entity_type);
var id = p.id;
var web = p.website;
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
var halalRow = '';
if (p.halal_score != null) {
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
}
var contactBits = [];
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
var contactRow = contactBits.length
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
: '';
return (
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
'<div class="ei-popup-top">' +
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
'</div>' +
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
'<div class="ei-popup-grid">' +
halalRow +
contactRow +
row('Stad', p.city) +
row('Adres', p.address_line) +
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
row('Website', p.website, web) +
row('Volume', p.volume_band) +
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
row('Pipeline', p.pipeline_stage) +
'</div>' +
'<div class="ei-popup-actions">' +
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
'</div>' +
'</div>'
);
}
function pinIcon(type, p) {
var c = cfg(type);
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
return L.divIcon({
className: 'ei-pin-wrap',
html:
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
'<span class="ei-pin-ring"></span>' +
'<span class="ei-pin-icon">' + c.icon + '</span>' +
'</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -16],
});
}
function openEntityId(rawId) {
var id = Number(rawId);
if (!Number.isFinite(id)) return;
if (onEntityClick) {
onEntityClick(id);
return;
}
try {
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
} catch (e) { /* empty */ }
}
function wirePopup(container) {
if (!container) return;
var popupRoot = container.closest('.leaflet-popup') || container;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(popupRoot);
L.DomEvent.disableScrollPropagation(popupRoot);
}
var btn = container.querySelector('.ei-popup-btn');
var id = btn && btn.getAttribute('data-entity-id');
if (btn && id) {
var onBtn = function (e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
openEntityId(id);
};
if (window.L && L.DomEvent) {
L.DomEvent.on(btn, 'click', onBtn);
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
} else {
btn.addEventListener('click', onBtn);
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
}
}
container.addEventListener('click', function (e) {
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
var card = e.target.closest('[data-entity-id]');
if (card) openEntityId(card.getAttribute('data-entity-id'));
});
}
function init(containerId) {
var el = document.getElementById(containerId);
if (!el || !window.L) return null;
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OSM &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
if (window.L.markerClusterGroup) {
clusterLayer = L.markerClusterGroup({
maxClusterRadius: 42,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: function (cluster) {
var n = cluster.getChildCount();
var size = n < 10 ? 'sm' : n < 50 ? 'md' : 'lg';
return L.divIcon({
html: '<div class="ei-cluster ei-cluster-' + size + '"><span>' + n + '</span></div>',
className: 'ei-cluster-wrap',
iconSize: L.point(44, 44),
});
},
});
map.addLayer(clusterLayer);
} else {
clusterLayer = L.layerGroup();
map.addLayer(clusterLayer);
}
map.on('popupopen', function (e) {
var root = e.popup && e.popup.getElement();
if (!root) return;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(root);
L.DomEvent.disableScrollPropagation(root);
}
wirePopup(root.querySelector('.leaflet-popup-content') || root);
});
return map;
}
function setFeatures(geojson) {
if (!map || !clusterLayer) return;
clusterLayer.clearLayers();
markersById = {};
var features = (geojson && geojson.features) || [];
var markers = [];
features.forEach(function (f) {
var coords = f.geometry && f.geometry.coordinates;
if (!coords) return;
var p = f.properties || {};
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) });
marker._eiId = p.id;
if (p.id != null) markersById[p.id] = marker;
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' });
marker.on('dblclick', function () {
if (onEntityClick && p.id) onEntityClick(p.id);
});
markers.push(marker);
});
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
if (features.length === 1) {
var c = features[0].geometry.coordinates;
map.setView([c[1], c[0]], 10);
} else if (markers.length > 1 && clusterLayer.getBounds) {
try {
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
} catch (e) { /* empty */ }
}
}
function setHalalMode(on) {
halalMode = !!on;
}
function setOnEntityClick(fn) {
onEntityClick = fn;
}
function flyTo(lat, lon, zoom) {
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
}
function highlightEntity(id) {
var marker = markersById[id];
if (!marker || !map) return;
var latlng = marker.getLatLng();
map.setView(latlng, Math.max(map.getZoom(), 14));
marker.openPopup();
}
function closePopup() {
if (map) map.closePopup();
}
function destroy() {
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
}
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
})();
+596
View File
@@ -0,0 +1,596 @@
function exportIntelApp() {
return {
tab: 'map',
tabIndicatorTop: 0,
tabIndicatorH: 40,
busy: false,
syncMsg: '',
stats: {},
regions: [],
territories: [],
country: '',
region: '',
entities: [],
contacts: [],
tenders: [],
catererPresence: [],
catererBrands: [],
govSources: [],
selected: null,
drawerOpen: false,
q: '',
entityTypeFilter: '',
mapTypesSelected: [],
mapHalalMode: false,
mapHalalMin: 55,
halalTopMarkets: [],
halalTopEntities: [],
listFocusId: null,
listFocusDetail: null,
listFocusLoading: false,
drawerLoading: false,
filterHasEmail: '',
filterFavoritesOnly: false,
filterCrmStatus: '',
selectedIds: [],
crmPipeline: [],
crmModalOpen: false,
crmCreateDeals: true,
crmPushBusy: false,
resultCount: 0,
_searchTimer: null,
tabs: [
{ id: 'map', icon: '🗺️', label: 'Kaart', color: '#0ea5e9', key: '1' },
{ id: 'distributors', icon: '📦', label: 'Distributeurs', color: '#fbbf24', key: '2' },
{ id: 'customers', icon: '🍽️', label: 'Eindklanten', color: '#34d399', key: '3' },
{ id: 'caterers', icon: '🏢', label: 'Cateraars', color: '#a78bfa', key: '4' },
{ id: 'contacts', icon: '📇', label: 'Contacten', color: '#38bdf8', key: '5' },
{ id: 'tenders', icon: '📋', label: 'Tenders', color: '#c084fc', key: '6' },
{ id: 'gov', icon: '🏛️', label: 'Overheid', color: '#94a3b8', key: '7' },
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
],
mapTypeChips: [
{ v: 'distributor', l: 'Distributeur', icon: '📦' },
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
{ v: 'importer', l: 'Importeur', icon: '🚢' },
{ v: 'logistics', l: 'Logistiek', icon: '🚚' },
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' },
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
{ v: 'butcher', l: 'Slager', icon: '🥩' },
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
{ v: 'foodservice', l: 'Foodservice', icon: '🍴' },
],
async init() {
var params = new URLSearchParams(location.search);
var t = params.get('tab');
if (t) this.tab = t;
var self = this;
document.addEventListener('export-intel:open-entity', function (ev) {
if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id);
});
await this.loadMeta();
await this.refresh();
this.$nextTick(() => this.updateTabIndicator());
window.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select')) return;
var n = parseInt(e.key, 10);
if (n >= 1 && n <= 8) {
var btn = this.$refs.vtabsNav?.querySelector('[data-tab="' + this.tabs[n - 1].id + '"]');
this.setTab(this.tabs[n - 1].id, btn);
}
});
},
async loadMeta() {
try {
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
} catch (e) {
console.error(e);
}
},
async refresh() {
this.busy = true;
try {
var cp = this.country ? '?country=' + encodeURIComponent(this.country) : '';
var rp = this.region ? (cp ? '&' : '?') + 'region=' + encodeURIComponent(this.region) : '';
this.stats = await fetch('/api/export-intel/stats' + cp + rp).then((r) => r.json());
await this.loadTabData();
if (this.tab === 'map') this.loadMap();
} catch (e) {
console.error(e);
}
this.busy = false;
},
filterQs() {
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
if (this.filterHasEmail === 'no') parts.push('has_email=false');
if (this.filterFavoritesOnly) parts.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
return parts.length ? '&' + parts.join('&') : '';
},
onSearchInput() {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.applyMapFilters(), 350);
},
clearFilters() {
this.q = '';
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
this.filterFavoritesOnly = false;
this.filterCrmStatus = '';
this.selectedIds = [];
this.applyMapFilters();
},
showEntityTypeFilter() {
return ['distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
},
showMapTypeFilter() {
return this.tab === 'map';
},
isMapTypeActive(v) {
return this.mapTypesSelected.indexOf(v) >= 0;
},
toggleMapType(v) {
var i = this.mapTypesSelected.indexOf(v);
if (i >= 0) this.mapTypesSelected.splice(i, 1);
else this.mapTypesSelected.push(v);
this.entityTypeFilter = '';
this.applyMapFilters();
},
clearMapTypes() {
this.mapTypesSelected = [];
this.applyMapFilters();
},
mapTypesParam() {
if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter };
if (this.mapTypesSelected.length) {
return { entity_types: this.mapTypesSelected.join(',') };
}
return {};
},
applyMapFilters() {
if (this.tab !== 'map') {
this.loadTabData();
return;
}
this.loadTabData().then(() => this.loadMap());
},
showEmailFilter() {
return this.tab === 'contacts';
},
entityTypeOptions() {
if (this.tab === 'distributors') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'importer', l: 'Importeur' },
{ v: 'logistics', l: 'Logistiek' },
];
}
if (this.tab === 'customers') {
return [
{ v: '', l: 'Alle types' },
{ v: 'restaurant', l: 'Restaurant' },
{ v: 'doner_shoarma', l: 'Döner / shoarma' },
{ v: 'butcher', l: 'Slager' },
{ v: 'foodservice', l: 'Foodservice' },
];
}
if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
}
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
];
},
async loadTabData() {
var base = '/api/export-intel';
var fq = this.filterQs();
if (this.tab === 'map') {
var mapQs = this.filterQs();
var mt = this.mapTypesParam();
if (mt.entity_type) mapQs += '&entity_type=' + encodeURIComponent(mt.entity_type);
else if (mt.entity_types) mapQs += '&entity_types=' + encodeURIComponent(mt.entity_types);
var rm = await fetch(base + '/entities?limit=200' + mapQs).then((x) => x.json());
this.entities = rm.items || [];
this.resultCount = rm.total != null ? rm.total : this.entities.length;
} else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics';
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
this.entities = r.items || [];
this.resultCount = r.total != null ? r.total : this.entities.length;
} else if (this.tab === 'customers') {
var types2 = 'restaurant,doner_shoarma,butcher,foodservice';
var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json());
this.entities = r2.items || [];
this.resultCount = r2.total != null ? r2.total : this.entities.length;
} else if (this.tab === 'caterers') {
var et3 = this.entityTypeFilter || 'contract_caterer';
var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
this.entities = r3.items || [];
this.resultCount = r3.total != null ? r3.total : this.entities.length;
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
} else if (this.tab === 'contacts') {
var r4 = await fetch(base + '/contacts?limit=500' + fq).then((x) => x.json());
this.contacts = r4.items || [];
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
} else if (this.tab === 'tenders') {
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
var items = tenders.items || [];
if (this.q && this.q.trim()) {
var ql = this.q.trim().toLowerCase();
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
}
this.tenders = items;
this.resultCount = items.length;
} else if (this.tab === 'gov') {
var gov = await fetch(base + '/gov-sources' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
if (this.q && this.q.trim()) {
var ql2 = this.q.trim().toLowerCase();
gov = gov.filter((g) => (g.name || '').toLowerCase().includes(ql2) || (g.category || '').toLowerCase().includes(ql2));
}
this.govSources = gov;
this.resultCount = gov.length;
} else if (this.tab === 'pipeline') {
var pipeQs = this.filterQs();
var pipe = await fetch(base + '/crm/pipeline?limit=200' + pipeQs).then((x) => x.json());
this.crmPipeline = pipe.items || [];
this.resultCount = this.crmPipeline.length;
}
},
async loadMap() {
this.clearListFocus();
var url = '/api/export-intel/map/bundle';
var qs = [];
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
if (this.region) qs.push('region=' + encodeURIComponent(this.region));
var mt = this.mapTypesParam();
if (mt.entity_type) qs.push('entity_type=' + encodeURIComponent(mt.entity_type));
else if (mt.entity_types) qs.push('entity_types=' + encodeURIComponent(mt.entity_types));
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
if (this.mapHalalMode) qs.push('halal_min=' + encodeURIComponent(this.mapHalalMin));
if (this.filterFavoritesOnly) qs.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
if (qs.length) url += '?' + qs.join('&');
var bundle = await fetch(url).then((r) => r.json());
this.halalTopMarkets = (bundle.meta && bundle.meta.top_markets) || [];
this.halalTopEntities = (bundle.meta && bundle.meta.top_halal_entities) || [];
if (window.ExportIntelMap) {
if (!document.getElementById('ei-map')) return;
var self = this;
window.ExportIntelMap.init('ei-map');
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] });
if (this.country) {
var t = this.territories.find((x) => x.country_iso2 === this.country);
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
}
}
},
setTab(id, el) {
this.tab = id;
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
var u = new URL(location.href);
u.searchParams.set('tab', id);
history.replaceState(null, '', u.pathname + u.search);
this.loadTabData();
if (id === 'map') this.$nextTick(() => this.loadMap());
this.$nextTick(() => this.updateTabIndicator(el));
},
updateTabIndicator(el) {
var nav = this.$refs.vtabsNav;
if (!nav) return;
var btn = el || nav.querySelector('.vtab-btn.active');
if (!btn) return;
var navRect = nav.getBoundingClientRect();
var btnRect = btn.getBoundingClientRect();
this.tabIndicatorTop = btnRect.top - navRect.top + nav.scrollTop;
this.tabIndicatorH = btnRect.height;
},
async onCountryChange() {
await this.refresh();
},
contactPreview(e) {
if (!e) return '—';
if (e.email || e.primary_email) return e.email || e.primary_email;
if (e.phone || e.primary_phone) return e.phone || e.primary_phone;
if (e.website) return e.website.replace(/^https?:\/\//, '').slice(0, 28);
if (e.has_contact) return 'Contact in registry';
return 'Geen contact';
},
async fetchEntityDetail(id) {
var r = await fetch('/api/export-intel/entities/' + id);
if (!r.ok) {
var err = await r.json().catch(function () { return {}; });
throw new Error((err && err.error) || ('HTTP ' + r.status));
}
var data = await r.json();
if (data && data.error) throw new Error(data.error);
return data;
},
async selectListEntity(id) {
if (!id) return;
this.listFocusId = id;
this.listFocusLoading = true;
this.listFocusDetail = null;
try {
var detail = await this.fetchEntityDetail(id);
this.listFocusDetail = detail;
if (window.ExportIntelMap && detail.lat && detail.lon) {
window.ExportIntelMap.flyTo(detail.lat, detail.lon, 14);
window.ExportIntelMap.highlightEntity(detail.id);
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.listFocusId = null;
}
this.listFocusLoading = false;
},
clearListFocus() {
this.listFocusId = null;
this.listFocusDetail = null;
},
async openEntity(id) {
if (!id) return;
this.drawerLoading = true;
this.drawerOpen = true;
try {
if (this.listFocusDetail && this.listFocusDetail.id === id) {
this.selected = this.listFocusDetail;
} else {
this.selected = await this.fetchEntityDetail(id);
this.listFocusId = id;
this.listFocusDetail = this.selected;
}
if (window.ExportIntelMap && this.selected.lat && this.selected.lon) {
window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14);
window.ExportIntelMap.highlightEntity(this.selected.id);
}
if (window.ExportIntelMap && window.ExportIntelMap.closePopup) {
window.ExportIntelMap.closePopup();
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.drawerOpen = false;
this.selected = null;
}
this.drawerLoading = false;
},
closeDrawer() {
this.drawerOpen = false;
this.selected = null;
this.drawerLoading = false;
},
async syncKind(kind) {
if (kind === 'world') {
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return;
}
if (kind === 'region' && !this.region) {
this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
return;
}
this.busy = true;
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
try {
var body = {
country_iso2: this.country || null,
region_code: kind === 'region' ? this.region : (this.region || null),
max_priority: 2,
};
if (kind === 'world') body = { max_priority: 2 };
var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
if (r.error) this.syncMsg = 'Fout: ' + r.error;
else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst';
else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid';
else this.syncMsg = 'Sync voltooid';
await this.refresh();
} catch (e) {
this.syncMsg = 'Sync mislukt: ' + e.message;
}
this.busy = false;
},
tabCount(id) {
if (id === 'distributors') return this.stats.distributors || 0;
if (id === 'caterers') return this.stats.caterers || 0;
if (id === 'contacts') return this.stats.contacts || 0;
if (id === 'tenders') return this.stats.tenders_open || 0;
if (id === 'pipeline') return this.stats.crm_linked || 0;
if (id === 'map') return this.stats.entities || 0;
return 0;
},
tabSub(id) {
var n = this.tabCount(id);
if (id === 'map') return n + ' op kaart';
if (id === 'gov') return 'Bronnen per land';
if (id === 'pipeline') return 'CRM koppeling';
if (n > 0) return n + ' records';
return '';
},
contactsExportUrl() {
var u = '/api/export-intel/contacts/export.csv?';
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
return u + parts.join('&');
},
typeLabel(t) {
return (t || '').replace(/_/g, ' ');
},
territoryName(iso2) {
if (!iso2) return '';
var t = this.territories.find(function (x) { return x.country_iso2 === iso2; });
return t ? t.name_nl : iso2;
},
isSelected(id) {
return this.selectedIds.indexOf(id) >= 0;
},
toggleSelect(id, ev) {
if (ev) ev.stopPropagation();
var i = this.selectedIds.indexOf(id);
if (i >= 0) this.selectedIds.splice(i, 1);
else this.selectedIds.push(id);
},
toggleSelectAllVisible() {
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id));
if (allSelected) {
list.forEach((e) => {
var i = this.selectedIds.indexOf(e.id);
if (i >= 0) this.selectedIds.splice(i, 1);
});
} else {
list.forEach((e) => {
if (!this.isSelected(e.id)) this.selectedIds.push(e.id);
});
}
},
clearSelection() {
this.selectedIds = [];
},
async setFavorites(ids, favorite) {
if (!ids.length) return;
this.busy = true;
try {
await fetch('/api/export-intel/entities/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_ids: ids, favorite: favorite }),
});
this.syncMsg = favorite
? ids.length + ' favoriet' + (ids.length > 1 ? 'en' : '') + ' opgeslagen'
: 'Favoriet verwijderd';
await this.refresh();
} catch (e) {
this.syncMsg = 'Favoriet mislukt: ' + e.message;
}
this.busy = false;
},
async toggleFavorite(id, current, ev) {
if (ev) ev.stopPropagation();
await this.setFavorites([id], !current);
},
async favoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), true);
},
async unfavoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), false);
},
openCrmModal() {
if (!this.selectedIds.length) {
this.syncMsg = 'Selecteer eerst één of meer rijen (checkbox)';
return;
}
this.crmModalOpen = true;
},
async pushToCrm(ids) {
var entityIds = (ids && ids.length) ? ids : this.selectedIds.slice();
if (!entityIds.length) return;
this.crmPushBusy = true;
this.syncMsg = 'CRM import gestart…';
try {
var r = await fetch('/api/export-intel/crm/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_ids: entityIds,
create_deals: this.crmCreateDeals,
}),
}).then((x) => x.json());
if (r.error) throw new Error(r.error);
var created = r.created || 0;
var linked = r.linked_existing || 0;
this.syncMsg = 'CRM: ' + created + ' nieuw, ' + linked + ' bestaand gekoppeld';
this.crmModalOpen = false;
this.selectedIds = [];
await this.refresh();
if (this.listFocusDetail && entityIds.indexOf(this.listFocusDetail.id) >= 0) {
this.listFocusDetail = await this.fetchEntityDetail(this.listFocusDetail.id);
}
if (this.tab === 'pipeline') await this.loadTabData();
} catch (e) {
this.syncMsg = 'CRM import mislukt: ' + e.message;
}
this.crmPushBusy = false;
},
async pushEntityToCrm(id) {
this.crmCreateDeals = true;
await this.pushToCrm([id]);
},
onFilterCollectionChange() {
this.applyMapFilters();
},
};
}
+373
View File
@@ -0,0 +1,373 @@
/**
* Herman Assistant — browser widget + gedeelde chat/voice logica
*/
window.HermanAssistant = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
butcher: 'Slager', doner: 'Döner',
};
return m[t] || t || '—';
}
function newSessionId(prefix) {
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
}
function baseState(opts) {
opts = opts || {};
return {
channel: opts.channel || 'browser',
sessionPrefix: opts.sessionPrefix || 'br',
open: false,
hidden: false,
status: 'idle',
statusDetail: '',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: true,
handsFreeMode: false,
useBrowserPreview: !!SpeechRecognition,
turns: [],
manualText: '',
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
sessionId: newSessionId(opts.sessionPrefix || 'br'),
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
holdIgnoreClick: false,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
initWidget: function () { /* overridden in browserWidget */ },
toggleOpen: function () {
this.open = !this.open;
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
},
scrollLog: function () {
var self = this;
this.$nextTick(function () {
var log = self.$refs.chatLog;
if (log) log.scrollTop = log.scrollHeight;
});
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.scrollLog();
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
},
closeResultsModal: function () { this.resultsOpen = false; },
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
},
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
this.manualText = 'ja';
this.sendText(this.pendingAction.id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendText();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '', final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
this.livePreview = '';
this.status = 'listening';
this.statusDetail = 'Luisteren…';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if (blob.size > 0) self.processVoice(blob);
else { self.status = 'idle'; self.statusDetail = ''; }
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast('Microfoon: ' + e.message, 'error');
}
},
stopListen: function () {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen: function () {
if (this.recording) this.stopListen();
else this.startListen();
},
micClass: function () {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
return '';
},
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
onMicUp: function () {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick: function () {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
async processVoice(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
var text = j.text || '';
this.livePreview = text;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'voice' });
this.handleHermanResponse(j.herman || {});
this.status = 'idle';
this.statusDetail = '';
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
async sendText(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
try {
var body = { message: text, channel: this.channel, session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.handleHermanResponse(r);
this.status = 'idle';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
}
this.busy = false;
},
formatDelegated: function (meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
}
return {
browserWidget: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
s.initWidget = function () {
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
try {
var raw = localStorage.getItem('herman_assistant_open');
if (raw === '1') this.open = true;
} catch (e) { /* empty */ }
};
return s;
},
chatPage: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
s.imagePrompt = '';
s.generating = false;
s.lastImage = '';
s.initChat = function () {
this.open = true;
};
s.generateImage = async function () {
if (!this.imagePrompt.trim()) return;
this.generating = true;
try {
var 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;
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
}
Cockpit.toast('Afbeelding gegenereerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.generating = false;
};
return s;
},
entityTypeLabel: entityTypeLabel,
};
})();
function hermanBrowserWidget() {
return HermanAssistant.browserWidget();
}
+1
View File
File diff suppressed because one or more lines are too long
+9 -2
View File
@@ -17,7 +17,14 @@ window.I18n = (function () {
function applyDom() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n');
if (k) el.textContent = t(k);
if (!k) return;
var text = t(k);
/* Sidebar nav: never replace whole link (destroys icons) */
if (el.classList && el.classList.contains('nav-pill')) {
var lbl = el.querySelector('.sidebar-nav-label');
if (lbl) { lbl.textContent = text; return; }
}
el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
var k = el.getAttribute('data-i18n-placeholder');
@@ -31,7 +38,7 @@ window.I18n = (function () {
}
async function loadBundle(loc) {
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
var r = await fetch('/static/i18n/' + loc + '.json?v=3');
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
strings = await r.json();
}
+143
View File
@@ -0,0 +1,143 @@
(function () {
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function pageMatches(page, filters) {
const q = (filters.search || '').trim().toLowerCase();
const terms = filters.terms || [];
const siteId = filters.siteId ? String(filters.siteId) : '';
const minWords = parseInt(filters.minWords, 10) || 0;
const minHype = parseInt(filters.minHype, 10) || 0;
if (siteId && String(page.site_id) !== siteId) return false;
if ((page.word_count || 0) < minWords) return false;
if ((page.hype_score || 0) < minHype) return false;
if (filters.changedOnly && !page.recently_changed) return false;
const hay = [
page.site_name,
page.title,
page.url,
page.excerpt,
page.content_read,
page.meta_description,
(page.headings || []).join(' '),
(page.signals || []).join(' '),
(page.links_sample || []).map(function (l) {
return (l.label || '') + ' ' + (l.href || '');
}).join(' '),
]
.join(' ')
.toLowerCase();
if (q && hay.indexOf(q) < 0) return false;
for (let i = 0; i < terms.length; i++) {
if (hay.indexOf(String(terms[i]).toLowerCase()) < 0) return false;
}
return true;
}
function sortPages(pages, col, dir) {
const mul = dir === 'asc' ? 1 : -1;
const copy = pages.slice();
copy.sort(function (a, b) {
let va;
let vb;
switch (col) {
case 'site_name':
va = (a.site_name || '').toLowerCase();
vb = (b.site_name || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'title':
va = (a.title || a.url || '').toLowerCase();
vb = (b.title || b.url || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'word_count':
return ((a.word_count || 0) - (b.word_count || 0)) * mul;
case 'links_count':
return ((a.links_count || 0) - (b.links_count || 0)) * mul;
case 'crawled_at':
va = a.crawled_at || '';
vb = b.crawled_at || '';
return va < vb ? -mul : va > vb ? mul : 0;
case 'hype_score':
default:
return ((a.hype_score || 0) - (b.hype_score || 0)) * mul;
}
});
return copy;
}
function filterPages(pages, filters) {
if (!pages || !pages.length) return [];
return pages.filter(function (p) {
return pageMatches(p, filters);
});
}
function highlightHtml(text, queries) {
const raw = text || '';
if (!raw) return '<span class="muted">Geen tekst</span>';
let html = esc(raw);
const list = [];
if (queries && queries.length) {
queries.forEach(function (q) {
if (q && String(q).trim()) list.push(String(q).trim());
});
}
list.sort(function (a, b) {
return b.length - a.length;
});
list.forEach(function (term) {
if (term.length < 2) return;
const re = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
html = html.replace(re, '<mark>$1</mark>');
});
return html;
}
function exportCsv(pages) {
const rows = [['site', 'title', 'url', 'words', 'hype', 'changed', 'crawled_at', 'excerpt']];
pages.forEach(function (p) {
rows.push([
p.site_name || '',
(p.title || '').replace(/"/g, '""'),
p.url || '',
String(p.word_count || 0),
String(p.hype_score || 0),
p.recently_changed ? 'yes' : 'no',
p.crawled_at || '',
(p.excerpt || '').replace(/"/g, '""').slice(0, 500),
]);
});
return rows
.map(function (r) {
return r.map(function (c) {
return '"' + c + '"';
}).join(',');
})
.join('\n');
}
function downloadCsv(pages, filename) {
const csv = exportCsv(pages);
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || 'parse-analyse.csv';
a.click();
URL.revokeObjectURL(a.href);
}
window.ParseIntel = {
filterPages: filterPages,
sortPages: sortPages,
highlightHtml: highlightHtml,
exportCsv: exportCsv,
downloadCsv: downloadCsv,
};
})();
+422
View File
@@ -0,0 +1,422 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+464
View File
@@ -0,0 +1,464 @@
/**
* Voice Live — push-to-talk, hands-free, export results popup, Herman bevestiging
*/
window.VoiceLive = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur',
wholesaler: 'Groothandel',
importer: 'Importeur',
logistics: 'Logistiek',
restaurant: 'Restaurant',
caterer: 'Cateraar',
butcher: 'Slager',
doner: 'Döner',
};
return m[t] || t || '—';
}
return {
page: function () {
return {
status: 'idle',
statusDetail: 'Houd de microfoon ingedrukt en spreek',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
conversationMode: true,
handsFreeMode: false,
speakReply: true,
useBrowserPreview: !!SpeechRecognition,
pipeline: [],
feed: [],
turns: [],
manualText: '',
holdIgnoreClick: false,
sessionId: 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
agentEs: null,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
init: function () {
var self = this;
this.connectAgentFeed();
window.addEventListener('keydown', function (e) {
if (e.code === 'Space' && !e.target.matches('input, textarea') && !self.recording && !self.busy) {
e.preventDefault();
self.startListen();
}
});
window.addEventListener('keyup', function (e) {
if (e.code === 'Space' && self.recording && !e.target.matches('input, textarea')) {
e.preventDefault();
self.stopListen();
}
});
},
destroy: function () {
if (this._resumeTimer) clearTimeout(this._resumeTimer);
if (this._silenceTimer) clearTimeout(this._silenceTimer);
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
if (this.agentEs) {
this.agentEs.close();
this.agentEs = null;
}
this._stopRecognition();
this._stopStream();
},
connectAgentFeed: function () {
var self = this;
if (typeof EventSource === 'undefined') return;
try {
this.agentEs = new EventSource('/api/agents/live/stream');
this.agentEs.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (!data || data.type === 'connected') return;
var ch = (data.channel || '').toLowerCase();
var agent = (data.agent || data.agent_name || '').toLowerCase();
if (ch !== 'voice' && agent !== 'herman' && agent !== 'voice' && agent !== 'sourcing') return;
self.feed.unshift({
at: nowTime(),
agent: data.agent || data.agent_name || 'agent',
message: data.message || data.title || '',
});
if (self.feed.length > 40) self.feed.pop();
} catch (e) { /* empty */ }
};
} catch (e) { /* empty */ }
},
pushPipeline: function (entries) {
if (!entries || !entries.length) return;
this.pipeline = this.pipeline.concat(entries);
if (this.pipeline.length > 60) this.pipeline = this.pipeline.slice(-60);
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.$nextTick(function () {
var log = document.getElementById('voice-chat-log');
if (log) log.scrollTop = log.scrollHeight;
});
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
needs_confirmation: h.needs_confirmation,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording && self.status !== 'error') {
self.statusDetail = 'Hands-free — luisteren…';
self.startListen();
}
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel resultaten';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Popup geopend',
detail: this.resultsTotal + ' resultaten',
status: 'done',
at: new Date().toISOString(),
}]);
},
closeResultsModal: function () {
this.resultsOpen = false;
},
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Agy build gestart',
detail: this.webbuilderProject,
status: 'running',
at: new Date().toISOString(),
}]);
},
closeWebbuilderModal: function () {
this.webbuilderOpen = false;
},
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
var id = this.pendingAction.id;
this.manualText = 'ja';
this.sendManual(id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendManual();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '';
var final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.onerror = function () { /* optional */ };
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this.livePreview = '';
this.liveInterim = false;
if (!this.handsFreeMode) this.pipeline = [];
this.status = 'listening';
this.statusDetail = this.handsFreeMode
? 'Hands-free — spreek je vraag'
: 'Spreek nu — laat los om te versturen';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if ((self.conversationMode || self.handsFreeMode) && blob.size > 0) self.processTurn(blob);
else if (blob.size > 0) {
self.status = 'idle';
self.statusDetail = 'Opname klaar';
}
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
var self = this;
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = 'Microfoon: ' + e.message;
Cockpit.toast(this.statusDetail, 'error');
}
},
stopListen() {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen() {
if (this.recording) this.stopListen();
else this.startListen();
},
toggleHandsFree() {
if (this.handsFreeMode && !this.recording && !this.busy) {
Cockpit.toast('Hands-free aan — ik luister na elk antwoord opnieuw', 'success');
this.startListen();
} else if (!this.handsFreeMode && this.recording) {
this.stopListen();
}
},
onHandsFreeChange() {
if (this.handsFreeMode) this.toggleHandsFree();
else if (this.recording) this.stopListen();
},
async processTurn(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
this.pushPipeline([{
phase: 'capture',
label: 'Audio verstuurd',
detail: Math.round(blob.size / 1024) + ' KB',
status: 'done',
at: new Date().toISOString(),
}]);
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice turn mislukt');
this.pushPipeline(j.pipeline || []);
var text = j.text || '';
this.livePreview = text;
this.liveInterim = false;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'whisper' });
var h = j.herman || {};
this.handleHermanResponse(h);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar — spreek opnieuw';
if (!h.needs_confirmation && h.reply) Cockpit.toast('Herman antwoordde', 'success');
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
this.pushPipeline([{ phase: 'error', label: 'Fout', detail: e.message, status: 'error', at: new Date().toISOString() }]);
Cockpit.toast(e.message, 'error');
this.maybeResumeListening(1500);
}
this.busy = false;
},
async sendManual(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Herman denkt na…';
var confirmId = confirmActionId || (this.pendingAction && this.pendingAction.id) || null;
if (confirmId && (text.toLowerCase() === 'ja' || confirmActionId)) {
/* confirm via button passes id explicitly */
}
this.pushPipeline([{ phase: 'herman', label: 'Tekst naar Herman', detail: text.slice(0, 120), status: 'running', at: new Date().toISOString() }]);
try {
var body = { message: text, channel: 'voice', session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
if (this.pipeline.length) this.pipeline[this.pipeline.length - 1].status = 'done';
this.addTurn('user', text, { source: 'typed' });
this.handleHermanResponse(r);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
this.statusDetail = e.message;
}
this.busy = false;
},
clearSession() {
this.turns = [];
this.pipeline = [];
this.feed = [];
this.livePreview = '';
this.manualText = '';
this.pendingAction = null;
this.resultsOpen = false;
this.sessionId = 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
this.status = 'idle';
this.statusDetail = 'Sessie gewist';
},
micClass() {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
if (this.handsFreeMode) return 'is-handsfree';
return '';
},
onMicDown() { this.holdIgnoreClick = true; this.startListen(); },
onMicUp() {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick() {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
formatDelegated(meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
},
};
})();