Ship Herman chat, Hermes KPI explorer, and intelligence analytics.

Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
Aissa
2026-08-01 17:50:45 +00:00
parent 62239f0a4e
commit 5e43b6b246
28 changed files with 2686 additions and 1149 deletions
+237 -62
View File
@@ -1,75 +1,250 @@
{% extends "base.html" %}
{% block title %}Intelligence · Analytics{% endblock %}
{% block extra_head %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
<link rel="stylesheet" href="/static/css/analytics.css?v=20" />
{% endblock %}
{% block content %}
<div class="analytics-shell" x-data="analyticsHub()" x-init="init()">
<header class="page-header">
<div>
<h1>Analytics Command Center <span class="live-badge" x-text="liveLabel">Live</span></h1>
<p class="subtitle">Volledige database analyse · filter · charts · realtime</p>
<small class="muted" x-text="'Laatst: '+updatedAt"></small>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Ververs</button>
<a class="btn btn-sm" href="/">Herman →</a>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
</div>
</header>
<aside class="analytics-filters panel">
<h3>Filters</h3>
<label>Keten<select x-model="f.chain" @change="refresh()"><option value="">Alle</option>
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
</select></label>
<label>Provincie<select x-model="f.province" @change="refresh()"><option value="">Alle</option>
<template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
</select></label>
<label>Deal stage<select x-model="f.stage" @change="refresh()"><option value="">Alle</option>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template>
</select></label>
<label>Agent<select x-model="f.agent" @change="refresh()"><option value="">Alle</option>
<template x-for="a in meta.agents||[]" :key="a.agent_name"><option :value="a.agent_name" x-text="a.agent_name"></option></template>
</select></label>
<label>Periode (dagen)<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh()"></label>
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset</button>
</aside>
<div class="analytics-main">
<div class="kpi-row analytics-kpis" id="analytics-kpis"></div>
<div class="analytics-charts">
<div class="panel chart-panel"><h4>Pipeline per stage</h4><canvas id="chart-deals"></canvas></div>
<div class="panel chart-panel"><h4>Klanten per stage</h4><canvas id="chart-clients"></canvas></div>
<div class="panel chart-panel"><h4>Supermarkten per keten</h4><canvas id="chart-chains"></canvas></div>
<div class="panel chart-panel"><h4>Provincie verdeling</h4><canvas id="chart-provinces"></canvas></div>
<div class="panel chart-panel"><h4>CRM partnerships</h4><canvas id="chart-partnerships"></canvas></div>
<div class="panel chart-panel"><h4>Agent activiteit</h4><canvas id="chart-agents"></canvas></div>
<div class="panel chart-panel"><h4>Events timeline</h4><canvas id="chart-timeline"></canvas></div>
<div class="panel chart-panel"><h4>RSS per categorie</h4><canvas id="chart-rss"></canvas></div>
<div class="panel chart-panel"><h4>Groothandels provincie</h4><canvas id="chart-wholesale"></canvas></div>
<div class="panel chart-panel"><h4>NAS sentiment</h4><canvas id="chart-sentiment"></canvas></div>
<div class="panel chart-panel"><h4>Top woorden NAS</h4><canvas id="chart-words"></canvas></div>
<div class="panel chart-panel"><h4>Halal kansen top 10</h4><canvas id="chart-opportunities"></canvas></div>
<div class="panel chart-panel"><h4>Actieve promo folders</h4><canvas id="chart-promo"></canvas></div>
<div class="panel chart-panel"><h4>Milestones status</h4><canvas id="chart-milestones"></canvas></div>
<div class="ax-shell" x-data="analyticsHub()" x-init="init()">
<!-- Hero -->
<section class="ax-hero">
<div class="ax-hero-glow" aria-hidden="true"></div>
<div class="ax-hero-grid">
<div class="ax-hero-copy">
<p class="ax-eyebrow">Foodlinkk · Intelligence</p>
<h1>Speelveld<span class="ax-live" x-text="liveLabel">Live</span></h1>
<p class="ax-lead" x-text="pulse.sub || 'Wat goed gaat en waar de kansen liggen.'"></p>
<p class="ax-goal" x-show="goals.tagline || goals.vision_text">
<span x-text="goals.tagline || ''"></span>
<span x-show="goals.vision_text" x-text="' · ' + shortGoal(goals.vision_text)"></span>
</p>
</div>
<div class="ax-pulse-orb" :class="'tone-' + (pulse.tone || 'mid')">
<svg viewBox="0 0 120 120" class="ax-ring">
<circle cx="60" cy="60" r="52" class="ax-ring-track"></circle>
<circle cx="60" cy="60" r="52" class="ax-ring-value"
:style="'stroke-dasharray:' + ringDash(pulse.score) + ' 999'"></circle>
</svg>
<div class="ax-pulse-core">
<strong class="ax-mono" x-text="pulse.score ?? '—'"></strong>
<span>pulse</span>
</div>
<p class="ax-pulse-label" x-text="pulse.label || ''"></p>
</div>
<div class="ax-hero-actions">
<button type="button" class="ax-btn ax-btn-primary" @click="refresh()" :disabled="loading">↻ Ververs</button>
<button type="button" class="ax-btn" @click="showFilters = !showFilters" x-text="showFilters ? 'Filters verbergen' : 'Filters'"></button>
<a class="ax-btn" href="/retail">Retail 360</a>
<a class="ax-btn" href="/revenue-cockpit">Revenue</a>
<small class="ax-updated" x-text="'Sync ' + updatedAt"></small>
</div>
</div>
<div class="grid-2">
<section class="panel"><h2>Recente deals</h2>
<table class="data-table"><thead><tr><th>Titel</th><th>Stage</th><th>Waarde</th></tr></thead>
<tbody id="analytics-deals-table"></tbody></table>
</section>
<section class="panel"><h2>Agent events</h2>
<table class="data-table"><thead><tr><th>Agent</th><th>Event</th><th>Tijd</th></tr></thead>
<tbody id="analytics-events-table"></tbody></table>
</section>
<div class="ax-filters" x-show="showFilters" x-cloak>
<label>Keten
<select x-model="f.chain" @change="refresh(false)"><option value="">Alle</option>
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
</select>
</label>
<label>Provincie
<select x-model="f.province" @change="refresh(false)"><option value="">Alle</option>
<template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
</select>
</label>
<label>Deal stage
<select x-model="f.stage" @change="refresh(false)"><option value="">Alle</option>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template>
</select>
</label>
<label>Periode
<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh(false)">
</label>
<button type="button" class="ax-btn" @click="resetFilters()">Reset</button>
</div>
</div>
</section>
<!-- Signal strip -->
<section class="ax-signals">
<template x-for="s in signals" :key="s.key">
<article class="ax-signal" :class="s.tone || ''">
<span class="ax-signal-label" x-text="s.label"></span>
<strong class="ax-mono" x-text="s.value"></strong>
<em x-text="s.hint"></em>
</article>
</template>
</section>
<!-- Wins vs Opportunities -->
<section class="ax-split">
<div class="ax-col ax-col-win">
<header class="ax-col-head">
<h2>Wat goed gaat</h2>
<span x-text="(pulse.wins || []).length + ' signalen'"></span>
</header>
<template x-for="w in (pulse.wins || [])" :key="w.id">
<a class="ax-insight win" :href="w.href || '#'">
<div class="ax-insight-top">
<strong x-text="w.title"></strong>
<span class="ax-score ax-mono" x-text="w.score"></span>
</div>
<p x-text="w.body"></p>
<div class="ax-insight-foot">
<span x-text="w.metric"></span>
<span class="ax-bar"><i :style="'width:' + Math.min(100, w.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.wins || []).length">Nog geen wins berekend.</p>
</div>
<div class="ax-col ax-col-opp">
<header class="ax-col-head">
<h2>Kansen</h2>
<span x-text="(pulse.opportunities || []).length + ' open'"></span>
</header>
<template x-for="o in (pulse.opportunities || [])" :key="o.id">
<a class="ax-insight opp" :href="o.href || '#'">
<div class="ax-insight-top">
<strong x-text="o.title"></strong>
<span class="ax-score ax-mono" x-text="o.score"></span>
</div>
<p x-text="o.body"></p>
<div class="ax-insight-foot">
<span><span class="ax-tag" x-show="o.tag" x-text="o.tag"></span> <span x-text="o.metric"></span></span>
<span class="ax-bar opp"><i :style="'width:' + Math.min(100, o.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.opportunities || []).length">Geen kansen gevonden.</p>
</div>
</section>
<!-- Attention + Hermes findings -->
<section class="ax-band">
<div class="ax-attention" x-show="(pulse.attention || []).length">
<h3>Aandacht</h3>
<div class="ax-att-row">
<template x-for="a in (pulse.attention || [])" :key="a.id">
<a class="ax-att-chip" :href="a.href || '#'">
<strong x-text="a.title"></strong>
<span x-text="a.body"></span>
</a>
</template>
</div>
</div>
<div class="ax-hermes">
<header class="ax-col-head">
<div>
<h2>Hermes bevindingen</h2>
<p class="ax-muted">Diepere analyse die Hermes hier later aanvult via <code>/analytics/api/findings</code></p>
</div>
<span class="ax-pill" x-text="(findings || []).length ? ((findings || []).length + ' findings') : 'wacht op Hermes'"></span>
</header>
<div class="ax-findings-grid" x-show="(findings || []).length">
<template x-for="f in findings" :key="f.id">
<article class="ax-finding" :class="'kind-' + (f.kind || 'insight')">
<div class="ax-finding-top">
<span class="ax-tag" x-text="f.kind || 'insight'"></span>
<time x-text="formatTime(f.created_at)"></time>
</div>
<h4 x-text="f.title"></h4>
<p x-text="f.body"></p>
<div class="ax-finding-foot">
<span class="ax-mono" x-show="f.score != null" x-text="'score ' + f.score"></span>
<span x-text="f.source || 'hermes'"></span>
</div>
</article>
</template>
</div>
<div class="ax-findings-empty" x-show="!(findings || []).length">
<div class="ax-findings-placeholder">
<strong>Nog geen Hermes-findings</strong>
<p>Automatische wins & kansen staan hierboven. Zodra Hermes diepere markt- of concurrentie-inzichten heeft, landen ze hier.</p>
</div>
</div>
</div>
</section>
<!-- Market radar -->
<section class="ax-radar">
<header class="ax-col-head">
<h2>Market radar</h2>
<span>live charts</span>
</header>
<div class="ax-charts">
<article class="ax-chart-card wide">
<h3>Agent pulse</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-timeline"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Partnership mix</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-partners"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Top halal-kansen</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-opps"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Supermarkten per keten</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-chains"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Provincie footprint</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-prov"></canvas></div>
</article>
</div>
</section>
<!-- Lists -->
<section class="ax-lists">
<article class="ax-list-card">
<h3>Halal opportunity board</h3>
<ul>
<template x-for="(o, idx) in (data.top_opportunities || []).slice(0,8)" :key="idx">
<li>
<span class="ax-rank ax-mono" x-text="idx + 1"></span>
<div>
<strong x-text="(o.chain || '') + ' · ' + (o.city || '')"></strong>
<div class="ax-mini-bar"><i :style="'width:' + Math.min(100, o.halal_opportunity_score || 0) + '%'"></i></div>
</div>
<em class="ax-mono" x-text="Math.round(o.halal_opportunity_score || 0)"></em>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Recente agent events</h3>
<ul class="ax-events">
<template x-for="e in (data.recent_events || []).slice(0,8)" :key="e.created_at + (e.title||'')">
<li>
<div>
<strong x-text="e.title || e.event_type"></strong>
<span x-text="e.agent_name"></span>
</div>
<time x-text="formatTime(e.created_at)"></time>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Coverage snapshot</h3>
<dl class="ax-dl">
<div><dt>Partnership dekking</dt><dd class="ax-mono" x-text="(pulse.cover_pct || 0) + '%'"></dd></div>
<div><dt>White space</dt><dd class="ax-mono" x-text="fmt(pulse.white_space)"></dd></div>
<div><dt>NAS docs</dt><dd class="ax-mono" x-text="fmt(kpis.nas_docs)"></dd></div>
<div><dt>SM contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_supermarket)"></dd></div>
<div><dt>GH contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_wholesaler)"></dd></div>
<div><dt>Pending approvals</dt><dd class="ax-mono" x-text="fmt(kpis.pending_approvals)"></dd></div>
</dl>
</article>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=2"></script>
<script src="/static/js/analytics.js?v=20"></script>
{% endblock %}