Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
|
||||
<header class="hm-neo-header page-header">
|
||||
<h1>Agents · Soul Registry</h1>
|
||||
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
|
||||
</header>
|
||||
|
||||
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
|
||||
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
|
||||
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
|
||||
<span class="vtab-icon">🧬</span><span>Souls</span>
|
||||
</button>
|
||||
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
|
||||
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="vtabs-content">
|
||||
<section x-show="tab==='souls'" x-cloak>
|
||||
<div class="hm-neo-kpi-row" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner">⚡</div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">orchestrator</div></div></div><div class="hm-neo-kpi-value">CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="agents-neo-grid">
|
||||
<template x-for="s in souls" :key="s.agent_key">
|
||||
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="agent-avatar-wrap">
|
||||
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')">
|
||||
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]">
|
||||
<span class="hair" x-show="hasHair(s.agent_key)"></span>
|
||||
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span>
|
||||
<span class="eye left"></span>
|
||||
<span class="eye right"></span>
|
||||
<span class="mouth"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="agent-status-dot" x-show="s.is_active !== false"></span>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
|
||||
<span class="agent-tag" x-text="s.agent_key"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
|
||||
<div class="agent-meta-row">
|
||||
<small x-text="(s.event_count||0) + ' events'"></small>
|
||||
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small>
|
||||
</div>
|
||||
<div class="hm-eq" style="margin-top:0.5rem"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="agent-soul-editor" x-show="selected" x-cloak>
|
||||
<div class="agent-editor-header">
|
||||
<div class="agent-avatar-wrap">
|
||||
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')">
|
||||
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]">
|
||||
<span class="hair" x-show="hasHair(selected?.agent_key)"></span>
|
||||
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
|
||||
<span class="eye left"></span>
|
||||
<span class="eye right"></span>
|
||||
<span class="mouth"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<h2><span x-text="selected?.display_name"></span> — <span x-text="selected?.role_title"></span></h2>
|
||||
<p style="margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text="selected?.responsibilities"></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;flex-shrink:0">
|
||||
<button class="btn btn-pulse-green" @click="save()" :disabled="saving">Opslaan</button>
|
||||
<a href="/settings?tab=permissions" class="btn btn-pulse-purple">Herman rechten →</a>
|
||||
</div>
|
||||
</div>
|
||||
<label>Rol titel<input class="form-input" x-model="form.role_title"></label>
|
||||
<label>Functie & werkzaamheden<textarea class="form-input" rows="3" x-model="form.responsibilities"></textarea></label>
|
||||
<label>Soul.md (karakter, tone of voice, regels)<textarea x-model="form.soul_md"></textarea></label>
|
||||
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-top:1rem">Recente activiteit</h3>
|
||||
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
|
||||
<div class="hub-feed-item"><span x-text="ev.created_at?.substring(0,16)"></span> <span x-text="ev.title||ev.event_type"></span></div>
|
||||
</template>
|
||||
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="tab==='mesh'" x-cloak>
|
||||
<div class="panel">
|
||||
<h2 style="margin-top:0;">Netwerk</h2>
|
||||
<p class="page-tip" style="margin-top:.25rem;">Communication mesh met Herman als centrale Co-CEO hub.</p>
|
||||
<div class="mesh-wrap">
|
||||
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
|
||||
</div>
|
||||
<div class="mesh-legend">
|
||||
<span>Groen = healthy</span>
|
||||
<span>Oranje = waarschuwing</span>
|
||||
<span>Blauw = idle</span>
|
||||
<span>Grijs = offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/agents-mesh.js"></script>
|
||||
<script>
|
||||
function agentsSoulHub() {
|
||||
return {
|
||||
tab: '{{ active_tab }}',
|
||||
tabIndicatorTop: 0,
|
||||
tabIndicatorH: 40,
|
||||
meshLoaded: false,
|
||||
souls: [], selected: null, form: {}, saving: false,
|
||||
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
|
||||
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
|
||||
avatarStyle(key) {
|
||||
const m = {
|
||||
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob',
|
||||
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl',
|
||||
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short',
|
||||
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses',
|
||||
};
|
||||
return m[key] || 'style-male-short';
|
||||
},
|
||||
hasHair(key) { return !!key; },
|
||||
hasGlasses(key) { return ['herman','research','finance','legal'].includes(key); },
|
||||
async init() {
|
||||
await this.load();
|
||||
this.$nextTick(() => {
|
||||
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
|
||||
if (activeBtn) this.updateTabIndicator(activeBtn);
|
||||
if (this.tab === 'mesh') this.mountMesh();
|
||||
});
|
||||
},
|
||||
updateTabIndicator(el) {
|
||||
if (!el || !this.$refs.vtabsNav) return;
|
||||
const navRect = this.$refs.vtabsNav.getBoundingClientRect();
|
||||
const btnRect = el.getBoundingClientRect();
|
||||
this.tabIndicatorTop = Math.max(0, btnRect.top - navRect.top);
|
||||
this.tabIndicatorH = btnRect.height || 40;
|
||||
},
|
||||
setTab(next, el) {
|
||||
this.tab = next;
|
||||
this.updateTabIndicator(el);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', next);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
if (next === 'mesh') this.mountMesh();
|
||||
},
|
||||
mountMesh() {
|
||||
if (this.meshLoaded || !window.AgentsMesh) return;
|
||||
this.meshLoaded = true;
|
||||
window.AgentsMesh.mount('agents-mesh-canvas');
|
||||
},
|
||||
async load() {
|
||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
||||
this.souls = r.items || [];
|
||||
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
|
||||
},
|
||||
async select(key) {
|
||||
const r = await fetch('/api/agents/souls/' + key).then(x => x.json());
|
||||
this.selected = r.soul;
|
||||
this.form = { role_title: r.soul.role_title, responsibilities: r.soul.responsibilities, soul_md: r.soul.soul_md };
|
||||
},
|
||||
async save() {
|
||||
if (!this.selected) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
await Cockpit.api('/api/agents/souls/' + this.selected.agent_key, { method: 'PUT', body: JSON.stringify(this.form) });
|
||||
Cockpit.toast('Soul opgeslagen', 'success');
|
||||
await this.load();
|
||||
await this.select(this.selected.agent_key);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.saving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
|
||||
<script src="/static/js/analytics.js?v=1"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,93 @@
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api"])
|
||||
|
||||
|
||||
def _stats_payload() -> dict[str, Any]:
|
||||
stats: dict[str, Any] = {
|
||||
"deals": 0,
|
||||
"clients": 0,
|
||||
"pending_approvals": 0,
|
||||
"pipeline_value": 0,
|
||||
}
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS c FROM deals")
|
||||
stats["deals"] = int(row["c"]) if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS c FROM clients")
|
||||
stats["clients"] = int(row["c"]) if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
|
||||
stats["pending_approvals"] = int(row["c"]) if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
row = fetch_one(
|
||||
"SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
|
||||
)
|
||||
stats["pipeline_value"] = float(row["total"]) if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/herman/briefing/stats")
|
||||
async def herman_briefing_stats():
|
||||
"""Live stats from DB — always fresh for dashboard panels."""
|
||||
return {"ok": True, "stats": serialize_stats(collect_briefing_data()), "at": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
@router.post("/herman/briefing")
|
||||
async def herman_briefing():
|
||||
try:
|
||||
content, stats = await generate_daily_briefing()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
@router.get("/herman/briefing/latest")
|
||||
async def herman_briefing_latest():
|
||||
try:
|
||||
row = fetch_one(
|
||||
"SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
live_stats = serialize_stats(collect_briefing_data())
|
||||
if not row:
|
||||
return {"ok": True, "content": None, "stats": live_stats}
|
||||
if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
|
||||
row["created_at"] = row["created_at"].isoformat()
|
||||
return {"ok": True, **row, "stats": live_stats}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(limit: int = 50):
|
||||
limit = max(1, min(limit, 200))
|
||||
try:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, created_at
|
||||
FROM agent_events ORDER BY created_at DESC LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
for row in rows:
|
||||
if row.get("created_at"):
|
||||
row["created_at"] = row["created_at"].isoformat()
|
||||
return {"events": rows}
|
||||
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
|
||||
<title>{% block title %}{{ page_title }} · Foodlinkk{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json?v=1" />
|
||||
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen }">
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
|
||||
<div class="app-sidebar-brand">
|
||||
<p class="brand-title">Foodlinkk</p>
|
||||
<p class="brand-name">Command Center</p>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}">Herman</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">CRM</div>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Intel</div>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser</a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Hermes</div>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Agents</div>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
|
||||
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}">Packaging</a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">System</div>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}">IT Ops</a>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="mobile-nav-overlay" x-show="navOpen" @click="navOpen=false" x-cloak></div>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-topbar">
|
||||
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen">☰</button>
|
||||
<span class="page-tip">Foodlinkk</span>
|
||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
||||
</header>
|
||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
|
||||
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/cockpit.js"></script>
|
||||
<script src="/static/js/live-pulse.js?v=2"></script>
|
||||
<script>
|
||||
function appShell() {
|
||||
return {
|
||||
drawerOpen: false,
|
||||
navOpen: false,
|
||||
showInstall: false,
|
||||
_deferredPrompt: null,
|
||||
clockDate: '—',
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
initApp() {
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
},
|
||||
async initClock() {
|
||||
try {
|
||||
const r = await fetch('/api/server-time').then(x => x.json());
|
||||
this._offset = new Date(r.utc).getTime() - Date.now();
|
||||
} catch (e) { this._offset = 0; }
|
||||
const tick = () => {
|
||||
const d = new Date(Date.now() + this._offset);
|
||||
this.clockDate = d.toLocaleDateString('nl-NL', { timeZone: 'Europe/Amsterdam', weekday: 'short', day: 'numeric', month: 'short' });
|
||||
this.clockTime = d.toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
},
|
||||
initPwa() {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return;
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.showInstall = !localStorage.getItem('pwa-dismiss');
|
||||
}
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this._deferredPrompt = e;
|
||||
this.showInstall = true;
|
||||
});
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
|
||||
}
|
||||
},
|
||||
async installPwa() {
|
||||
if (this._deferredPrompt) {
|
||||
this._deferredPrompt.prompt();
|
||||
await this._deferredPrompt.userChoice;
|
||||
this._deferredPrompt = null;
|
||||
this.showInstall = false;
|
||||
return;
|
||||
}
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
|
||||
} else {
|
||||
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
|
||||
}
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,221 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/beurs.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="beurs-shell" x-data="beursHub()" x-init="init()">
|
||||
|
||||
<header class="beurs-hero">
|
||||
<div>
|
||||
<h1>Beurs & Live Intel <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">Supermarkt aandelen · halal trends · concept lab · platform events</p>
|
||||
<small x-text="'Laatste refresh: ' + (lastRefresh || '—')"></small>
|
||||
</div>
|
||||
<div class="beurs-hero-actions">
|
||||
<button class="btn btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
|
||||
<a href="/" class="btn">Herman Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="neo-tab-bar" role="tablist">
|
||||
<button type="button" class="neo-tab" :class="{ active: tab === 'beurs' }" @click="setTab('beurs')">
|
||||
<span class="neo-tab-icon">📈</span><span>Beurs Live</span>
|
||||
</button>
|
||||
<button type="button" class="neo-tab" :class="{ active: tab === 'trends' }" @click="setTab('trends')">
|
||||
<span class="neo-tab-icon">☪️</span><span>Halal & Trends</span>
|
||||
</button>
|
||||
<button type="button" class="neo-tab" :class="{ active: tab === 'concepten' }" @click="setTab('concepten')">
|
||||
<span class="neo-tab-icon">💡</span><span>Concept Lab</span>
|
||||
</button>
|
||||
<button type="button" class="neo-tab" :class="{ active: tab === 'events' }" @click="setTab('events')">
|
||||
<span class="neo-tab-icon">⚡</span><span>Platform Live</span>
|
||||
<span class="neo-tab-badge" x-show="platformStats.pending_approvals" x-text="platformStats.pending_approvals"></span>
|
||||
</button>
|
||||
<div class="neo-tab-indicator" :style="indicatorStyle"></div>
|
||||
</div>
|
||||
|
||||
<!-- BEURS LIVE -->
|
||||
<section x-show="tab === 'beurs'" x-cloak class="beurs-section">
|
||||
<div class="beurs-summary-row">
|
||||
<div class="beurs-stat-card">
|
||||
<span>Gem. dag %</span>
|
||||
<strong :class="(summary.avg_change_pct||0) >= 0 ? 'up' : 'down'" x-text="((summary.avg_change_pct||0) >= 0 ? '+' : '') + (summary.avg_change_pct||0) + '%'"></strong>
|
||||
</div>
|
||||
<div class="beurs-stat-card">
|
||||
<span>Beursgenoteerd</span>
|
||||
<strong x-text="summary.listed_count || listed.length"></strong>
|
||||
</div>
|
||||
<div class="beurs-stat-card">
|
||||
<span>NL privé ketens</span>
|
||||
<strong x-text="summary.unlisted_nl_count || unlisted.length"></strong>
|
||||
</div>
|
||||
<div class="beurs-stat-card source-card">
|
||||
<span>Databron</span>
|
||||
<strong><a href="https://finance.yahoo.com" target="_blank" rel="noopener">Yahoo Finance ↗</a></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label"><span class="hm-live-dot"></span> Beursgenoteerde supermarkten</h2>
|
||||
<div class="beurs-grid">
|
||||
<template x-for="s in listed" :key="s.symbol">
|
||||
<article class="beurs-card" :class="'trend-' + (s.trend || 'flat')">
|
||||
<div class="beurs-card-head">
|
||||
<div class="beurs-chain-dot" :style="'background:' + (s.color || '#38bdf8')"></div>
|
||||
<div>
|
||||
<strong x-text="s.symbol"></strong>
|
||||
<small x-text="s.name"></small>
|
||||
</div>
|
||||
<span class="beurs-pct" :class="(s.change_pct||0) >= 0 ? 'up' : 'down'"
|
||||
x-text="((s.change_pct||0) >= 0 ? '▲' : '▼') + ' ' + Math.abs(s.change_pct||0).toFixed(2) + '%'"></span>
|
||||
</div>
|
||||
<div class="beurs-chains" x-text="(s.chains||[]).join(' · ')"></div>
|
||||
<div class="beurs-price" x-show="s.price != null">
|
||||
<span x-text="Number(s.price).toFixed(2)"></span>
|
||||
<small x-text="s.currency"></small>
|
||||
<span class="market-state" x-text="s.market_state"></span>
|
||||
</div>
|
||||
<svg class="beurs-spark" x-show="(s.sparkline||[]).length" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
<polyline fill="none" stroke-width="2" :stroke="(s.change_pct||0) >= 0 ? '#4ade80' : '#fb7185'" :points="sparkPoints(s.sparkline)"/>
|
||||
</svg>
|
||||
<div class="beurs-eq"><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="beurs-sources">
|
||||
<a class="btn-source" :href="s.source_url" target="_blank" rel="noopener">Yahoo Finance ↗</a>
|
||||
<small x-text="s.exchange"></small>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label" style="margin-top:1.5rem">Nederlandse ketens · niet beursgenoteerd</h2>
|
||||
<p class="hint">Jumbo, Plus, Dirk, Lidl en ALDI zijn geen beursgenoteerde entiteiten — geen live koers via Yahoo Finance.</p>
|
||||
<div class="beurs-grid unlisted">
|
||||
<template x-for="u in unlisted" :key="u.name">
|
||||
<article class="beurs-card unlisted-card">
|
||||
<div class="beurs-card-head">
|
||||
<div class="beurs-chain-dot" :style="'background:' + (u.color || '#64748b')"></div>
|
||||
<div>
|
||||
<strong x-text="u.name"></strong>
|
||||
<small x-text="u.parent"></small>
|
||||
</div>
|
||||
<span class="beurs-tag">Privé</span>
|
||||
</div>
|
||||
<div class="beurs-chains" x-text="(u.chains||[]).join(' · ')"></div>
|
||||
<p class="unlisted-note" x-text="u.note || u.exchange"></p>
|
||||
<div class="beurs-sources">
|
||||
<a class="btn-source" :href="u.source_url" target="_blank" rel="noopener">Keten info ↗</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HALAL & TRENDS -->
|
||||
<section x-show="tab === 'trends'" x-cloak class="beurs-section">
|
||||
<div class="beurs-two-col">
|
||||
<div class="panel-inner">
|
||||
<h2>☪️ Halal vlees trends <span class="live-badge">RSS Live</span></h2>
|
||||
<template x-for="h in trends.halal_meat_trends" :key="h.link">
|
||||
<div class="feed-card">
|
||||
<span class="tag tag-halal">HALAL VLEES</span>
|
||||
<a :href="h.source_url || h.link" target="_blank" rel="noopener"><strong x-text="h.title"></strong></a>
|
||||
<p x-text="(h.summary||'').substring(0,160)"></p>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="h.link" target="_blank">Artikel ↗</a>
|
||||
<a class="btn-source" :href="h.feed_url" target="_blank">Bron feed ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!trends.halal_meat_trends?.length" class="muted">Geen halal vlees trends — refresh RSS feeds.</p>
|
||||
</div>
|
||||
<div class="panel-inner">
|
||||
<h2>🍱 Top gerechten & maaltijden</h2>
|
||||
<template x-for="d in trends.top_dishes" :key="d.dish_keyword">
|
||||
<div class="feed-card dish-card">
|
||||
<div class="dish-rank">
|
||||
<strong x-text="d.dish_keyword"></strong>
|
||||
<span class="tag tag-dish" x-text="d.mentions + '× in feed'"></span>
|
||||
</div>
|
||||
<a x-show="d.latest_link" :href="d.source_url" target="_blank" rel="noopener">
|
||||
<small x-text="d.latest_title"></small>
|
||||
</a>
|
||||
<div class="feed-actions" x-show="d.latest_link">
|
||||
<a class="btn-source" :href="d.latest_link" target="_blank">Bron artikel ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<h3 style="margin-top:1.25rem">Markt trends DB</h3>
|
||||
<template x-for="t in trends.market_trends" :key="t.trend_name">
|
||||
<div class="feed-card">
|
||||
<strong x-text="t.trend_name"></strong>
|
||||
<p x-text="t.description"></p>
|
||||
<span class="tag tag-trend" x-text="'Kans ' + Math.round((t.opportunity_score||0)*100) + '%'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONCEPT LAB -->
|
||||
<section x-show="tab === 'concepten'" x-cloak class="beurs-section">
|
||||
<div class="concept-header">
|
||||
<h2>💡 Concept Lab</h2>
|
||||
<p class="hint">Automatisch gegenereerde productconcepten op basis van live beurs, halal trends en top gerechten.</p>
|
||||
<button class="btn btn-pulse-green" @click="loadConcepts()" :disabled="busy">↻ Nieuwe concepten</button>
|
||||
</div>
|
||||
<div class="concept-grid">
|
||||
<template x-for="c in concepts" :key="c.id">
|
||||
<article class="concept-card">
|
||||
<span class="concept-num" x-text="'#' + c.id"></span>
|
||||
<p x-text="c.concept"></p>
|
||||
<div class="concept-based">
|
||||
<small x-text="'Gebaseerd op: ' + (c.based_on?.dish || '') + ' · ' + (c.based_on?.market_signal || '')"></small>
|
||||
</div>
|
||||
<div class="feed-actions">
|
||||
<template x-for="(url, i) in (c.source_urls||[])" :key="i">
|
||||
<a class="btn-source" :href="url" target="_blank" rel="noopener">Bron <span x-text="i+1"></span> ↗</a>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PLATFORM LIVE -->
|
||||
<section x-show="tab === 'events'" x-cloak class="beurs-section">
|
||||
<div class="beurs-summary-row">
|
||||
<div class="beurs-stat-card"><span>Events totaal</span><strong x-text="platformStats.total_events||0"></strong></div>
|
||||
<div class="beurs-stat-card"><span>Laatste uur</span><strong x-text="platformStats.events_last_hour||0"></strong></div>
|
||||
<div class="beurs-stat-card warn"><span>Wacht op OK</span><strong x-text="platformStats.pending_approvals||0"></strong></div>
|
||||
</div>
|
||||
<div class="events-toolbar">
|
||||
<select class="form-input" x-model="eventFilter" @change="loadEvents()">
|
||||
<option value="">Alle agents</option>
|
||||
<option value="herman">Herman</option>
|
||||
<option value="retail_intel">Retail Intel</option>
|
||||
<option value="rss_feeds">RSS Feeds</option>
|
||||
<option value="wholesale_scraper">Wholesale</option>
|
||||
<option value="halal_registry">Halal</option>
|
||||
<option value="hermes">Hermes</option>
|
||||
</select>
|
||||
<span class="live-badge">Auto-refresh 5s</span>
|
||||
</div>
|
||||
<div class="events-stream">
|
||||
<template x-for="ev in events" :key="ev.id">
|
||||
<a class="event-row" :href="ev.click_url" :target="ev.is_external ? '_blank' : '_self'" rel="noopener">
|
||||
<span class="event-time" x-text="(ev.created_at||'').substring(11,16)"></span>
|
||||
<span class="event-agent badge" x-text="ev.agent_name"></span>
|
||||
<span class="event-title" x-text="ev.title"></span>
|
||||
<span class="event-status" :class="'st-'+ev.status" x-text="ev.status"></span>
|
||||
<span class="event-source" x-text="ev.is_external ? '↗ extern' : '→ platform'"></span>
|
||||
</a>
|
||||
</template>
|
||||
<p x-show="!events.length" class="muted">Geen events gelogd.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/beurs.js?v=1"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,212 @@
|
||||
window.BriefingCharts = (function () {
|
||||
var charts = {};
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(charts).forEach(function (k) {
|
||||
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function parseContent(content) {
|
||||
var parts = (content || '').split(/\n---\n/);
|
||||
var ai = parts[0] || '';
|
||||
var summary = '';
|
||||
var actions = [];
|
||||
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|$)/i);
|
||||
if (sm) summary = sm[1].trim();
|
||||
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*)/i);
|
||||
if (am) {
|
||||
actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
|
||||
}
|
||||
if (!summary && ai.trim()) summary = ai.trim().slice(0, 600);
|
||||
return { summary: summary, actions: actions };
|
||||
}
|
||||
|
||||
function chartColors() {
|
||||
return {
|
||||
gold: 'rgba(232, 168, 56, 0.85)',
|
||||
goldDim: 'rgba(232, 168, 56, 0.35)',
|
||||
cyan: 'rgba(56, 189, 248, 0.85)',
|
||||
green: 'rgba(62, 207, 142, 0.85)',
|
||||
red: 'rgba(239, 68, 68, 0.85)',
|
||||
gray: 'rgba(107, 114, 128, 0.85)',
|
||||
grid: 'rgba(148, 163, 184, 0.12)',
|
||||
text: '#94a3b8',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPipeline(canvas, stats) {
|
||||
if (!canvas) return;
|
||||
var rows = stats.deals_by_stage || [];
|
||||
var c = chartColors();
|
||||
charts.pipeline = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(function (r) { return r.stage || '?'; }),
|
||||
datasets: [{
|
||||
label: 'Pipeline EUR',
|
||||
data: rows.map(function (r) { return Number(r.total) || 0; }),
|
||||
backgroundColor: c.gold,
|
||||
borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
|
||||
scales: {
|
||||
y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } },
|
||||
x: { ticks: { color: c.text }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderSentiment(canvas, stats) {
|
||||
if (!canvas) return;
|
||||
var files = stats.nas_files || [];
|
||||
var counts = { positive: 0, neutral: 0, negative: 0 };
|
||||
files.forEach(function (f) {
|
||||
var s = (f.sentiment_label || 'neutral').toLowerCase();
|
||||
if (counts[s] !== undefined) counts[s]++;
|
||||
});
|
||||
if (!files.length) { counts.neutral = 1; }
|
||||
var c = chartColors();
|
||||
charts.sentiment = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Positief', 'Neutraal', 'Negatief'],
|
||||
datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderWords(canvas, stats) {
|
||||
if (!canvas) return;
|
||||
var rows = (stats.top_words || []).slice(0, 8);
|
||||
var c = chartColors();
|
||||
charts.words = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(function (r) { return r.lemma; }),
|
||||
datasets: [{
|
||||
label: 'Count',
|
||||
data: rows.map(function (r) { return Number(r.total) || 0; }),
|
||||
backgroundColor: c.cyan,
|
||||
borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
|
||||
scales: {
|
||||
x: { ticks: { color: c.text }, grid: { color: c.grid } },
|
||||
y: { ticks: { color: c.text }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderAgents(canvas, stats) {
|
||||
if (!canvas) return;
|
||||
var events = stats.recent_events || [];
|
||||
var map = {};
|
||||
events.forEach(function (e) {
|
||||
var a = e.agent_name || 'other';
|
||||
map[a] = (map[a] || 0) + 1;
|
||||
});
|
||||
var labels = Object.keys(map);
|
||||
var values = labels.map(function (k) { return map[k]; });
|
||||
var c = chartColors();
|
||||
charts.agents = new Chart(canvas, {
|
||||
type: 'polarArea',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray, c.goldDim] }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
|
||||
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderKpis(container, stats) {
|
||||
if (!container) return;
|
||||
var items = [
|
||||
{ label: 'Pipeline', value: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), cls: 'green' },
|
||||
{ label: 'Klanten', value: stats.clients || 0, cls: 'purple' },
|
||||
{ label: 'Deals', value: stats.deals || 0, cls: '' },
|
||||
{ label: 'NAS docs', value: stats.nas_docs || 0, cls: 'cyan' },
|
||||
{ label: 'Goedkeuringen', value: stats.pending_approvals || 0, cls: 'amber' },
|
||||
];
|
||||
container.innerHTML = items.map(function (it) {
|
||||
return '<div class="briefing-stat ' + it.cls + '"><span class="briefing-stat-label">' + it.label + '</span><span class="briefing-stat-value">' + it.value + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNasList(container, stats) {
|
||||
if (!container) return;
|
||||
var files = stats.nas_files || [];
|
||||
if (!files.length) { container.innerHTML = '<p class="empty-state">Geen NAS documenten.</p>'; return; }
|
||||
container.innerHTML = '<table class="data-table"><thead><tr><th>Bestand</th><th>Type</th><th>Sentiment</th><th>Woorden</th></tr></thead><tbody>' +
|
||||
files.map(function (f) {
|
||||
var badge = 'badge-' + (f.sentiment_label || 'neutral');
|
||||
return '<tr><td>' + (f.filename || '') + '</td><td>' + (f.doc_type || '') + '</td><td><span class="badge ' + badge + '">' + (f.sentiment_label || '') + '</span></td><td>' + (f.word_count || '—') + '</td></tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
}
|
||||
|
||||
function renderPending(container, stats) {
|
||||
if (!container) return;
|
||||
var items = stats.pending_items || [];
|
||||
if (!items.length) { container.innerHTML = '<p class="empty-state">Geen openstaande goedkeuringen 🎉</p>'; return; }
|
||||
container.innerHTML = items.map(function (it) {
|
||||
return '<div class="briefing-pending-item"><span class="badge badge-agent-' + (it.agent_name || '').toLowerCase() + '">' + (it.agent_name || '') + '</span> ' + (it.title || it.event_type || '') + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render(root, stats, content, createdAt) {
|
||||
if (!root || !stats) return;
|
||||
destroyAll();
|
||||
var parsed = parseContent(content);
|
||||
|
||||
root.style.display = 'block';
|
||||
var empty = document.getElementById('briefing-empty');
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
var meta = document.getElementById('briefing-meta');
|
||||
if (meta) meta.textContent = createdAt ? ('Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ')) : '';
|
||||
|
||||
var sumEl = document.getElementById('briefing-summary-text');
|
||||
if (sumEl) sumEl.textContent = parsed.summary || (content ? 'Geen samenvatting.' : 'Klik op «Genereer dagrapport» voor een AI-samenvatting en actiepunten.');
|
||||
|
||||
var actEl = document.getElementById('briefing-actions-list');
|
||||
if (actEl) {
|
||||
actEl.innerHTML = parsed.actions.length
|
||||
? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('')
|
||||
: '<li>Geen actiepunten — genereer opnieuw.</li>';
|
||||
}
|
||||
|
||||
renderKpis(document.getElementById('briefing-kpis'), stats);
|
||||
renderPipeline(document.getElementById('chart-pipeline'), stats);
|
||||
renderSentiment(document.getElementById('chart-sentiment'), stats);
|
||||
renderWords(document.getElementById('chart-words'), stats);
|
||||
renderAgents(document.getElementById('chart-agents'), stats);
|
||||
renderNasList(document.getElementById('briefing-nas-list'), stats);
|
||||
renderPending(document.getElementById('briefing-pending-list'), stats);
|
||||
|
||||
var full = document.getElementById('briefing-full-text');
|
||||
if (full) full.textContent = content || '';
|
||||
}
|
||||
|
||||
return { render: render, destroyAll: destroyAll, parseContent: parseContent };
|
||||
})();
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import ollama
|
||||
|
||||
|
||||
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
|
||||
try:
|
||||
clause = f" WHERE {where}" if where else ""
|
||||
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
|
||||
return int(row["c"]) if row else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_sum(table: str, column: str, where: str = "", params: tuple = ()) -> float:
|
||||
try:
|
||||
clause = f" WHERE {where}" if where else ""
|
||||
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}", params or None)
|
||||
return float(row["total"]) if row else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def serialize_stats(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""JSON-safe copy of briefing stats (datetimes, decimals)."""
|
||||
|
||||
def _default(o: Any) -> Any:
|
||||
if hasattr(o, "isoformat"):
|
||||
return o.isoformat()
|
||||
if hasattr(o, "__float__"):
|
||||
try:
|
||||
return float(o)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return str(o)
|
||||
|
||||
return json.loads(json.dumps(data, default=_default))
|
||||
|
||||
|
||||
def collect_briefing_data() -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"date": date.today().isoformat(),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
data["clients"] = _safe_count("clients")
|
||||
data["deals"] = _safe_count("deals")
|
||||
data["products"] = _safe_count("products")
|
||||
data["suppliers"] = _safe_count("suppliers")
|
||||
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
|
||||
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
|
||||
|
||||
try:
|
||||
data["deals_by_stage"] = fetch_all(
|
||||
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
|
||||
)
|
||||
except Exception:
|
||||
data["deals_by_stage"] = []
|
||||
|
||||
try:
|
||||
data["recent_clients"] = fetch_all(
|
||||
"SELECT name, stage, email, created_at FROM clients ORDER BY created_at DESC LIMIT 5"
|
||||
)
|
||||
except Exception:
|
||||
data["recent_clients"] = []
|
||||
|
||||
try:
|
||||
data["recent_events"] = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, event_type, title, status, created_at
|
||||
FROM agent_events ORDER BY created_at DESC LIMIT 12
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
|
||||
try:
|
||||
data["pending_items"] = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, event_type, created_at
|
||||
FROM agent_events WHERE status = 'needs_approval'
|
||||
ORDER BY created_at DESC LIMIT 8
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
data["pending_items"] = []
|
||||
|
||||
try:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*) AS docs, COALESCE(SUM(word_count), 0) AS words,
|
||||
COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
|
||||
FROM document_analytics
|
||||
"""
|
||||
)
|
||||
data["nas_docs"] = int(row["docs"] or 0) if row else 0
|
||||
data["nas_words"] = int(row["words"] or 0) if row else 0
|
||||
data["nas_sentiment"] = round(float(row["avg_sentiment"] or 0), 3) if row else 0.0
|
||||
except Exception:
|
||||
data["nas_docs"] = data["nas_words"] = 0
|
||||
data["nas_sentiment"] = 0.0
|
||||
|
||||
try:
|
||||
data["nas_files"] = fetch_all(
|
||||
"""
|
||||
SELECT filename, doc_type, sentiment_label, word_count
|
||||
FROM document_analytics ORDER BY analyzed_at DESC LIMIT 8
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
data["nas_files"] = []
|
||||
|
||||
try:
|
||||
data["top_words"] = fetch_all(
|
||||
"""
|
||||
SELECT lemma, SUM(count) AS total FROM document_word_counts
|
||||
WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 10
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
data["top_words"] = []
|
||||
|
||||
try:
|
||||
data["calendar_events"] = fetch_all(
|
||||
"""
|
||||
SELECT ce.title, ce.starts_at, ce.ends_at, c.name AS client_name
|
||||
FROM calendar_events ce
|
||||
LEFT JOIN clients c ON c.id = ce.client_id
|
||||
WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
|
||||
AND ce.starts_at <= NOW() + INTERVAL '7 days'
|
||||
ORDER BY ce.starts_at ASC LIMIT 10
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
data["calendar_events"] = []
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def build_template_report(data: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"# Foodlinkk Dagrapport — {data['date']}",
|
||||
"",
|
||||
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
|
||||
"",
|
||||
"## KPI's",
|
||||
f"- **Klanten:** {data['clients']}",
|
||||
f"- **Deals totaal:** {data['deals']}",
|
||||
f"- **Pipeline (actief):** €{data['pipeline_eur']:,.0f}",
|
||||
f"- **Producten:** {data['products']}",
|
||||
f"- **Leveranciers:** {data['suppliers']}",
|
||||
f"- **Openstaande goedkeuringen:** {data['pending_approvals']}",
|
||||
"",
|
||||
"## Pipeline per stage",
|
||||
]
|
||||
if data.get("deals_by_stage"):
|
||||
for row in data["deals_by_stage"]:
|
||||
lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
|
||||
else:
|
||||
lines.append("- Geen deals in database.")
|
||||
|
||||
lines.extend(["", "## Recente klanten"])
|
||||
for row in data.get("recent_clients") or []:
|
||||
lines.append(f"- {row.get('name')} ({row.get('stage')})")
|
||||
if not data.get("recent_clients"):
|
||||
lines.append("- Geen klanten.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## NAS share — documenten",
|
||||
f"- Ingelezen documenten: **{data['nas_docs']}**",
|
||||
f"- Totaal woorden geanalyseerd: **{data['nas_words']}**",
|
||||
f"- Gemiddeld sentiment: **{data['nas_sentiment']}**",
|
||||
"",
|
||||
])
|
||||
for row in data.get("nas_files") or []:
|
||||
lines.append(f"- {row.get('filename')} · {row.get('doc_type')} · sentiment: {row.get('sentiment_label')}")
|
||||
|
||||
if data.get("top_words"):
|
||||
lines.extend(["", "## Top woorden (NAS corpus)"])
|
||||
for row in data["top_words"]:
|
||||
lines.append(f"- {row.get('lemma')}: {row.get('total')}×")
|
||||
|
||||
if data.get("calendar_events"):
|
||||
lines.extend(["", "## Agenda (7 dagen)"])
|
||||
for row in data["calendar_events"]:
|
||||
ts = row.get("starts_at")
|
||||
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
|
||||
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
|
||||
|
||||
lines.extend(["", "## Recente agent activiteit"])
|
||||
for row in data.get("recent_events") or []:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
|
||||
lines.append(f"- [{ts_s}] **{row.get('agent_name')}** — {row.get('title') or row.get('event_type')}")
|
||||
|
||||
if data.get("pending_items"):
|
||||
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
|
||||
for row in data["pending_items"]:
|
||||
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _ai_executive_summary(data: dict[str, Any], template: str) -> str:
|
||||
prompt = (
|
||||
"Schrijf alleen deze twee secties in het Nederlands (markdown):\n"
|
||||
"## Samenvatting\n(4-6 zinnen voor CEO Aïssa)\n\n"
|
||||
"## Actiepunten vandaag\n(minimaal 5 concrete bullets)\n\n"
|
||||
f"Gebaseerd op:\n- Pipeline €{data['pipeline_eur']:,.0f}\n"
|
||||
f"- {data['clients']} klanten, {data['deals']} deals\n"
|
||||
f"- {data['pending_approvals']} goedkeuringen open\n"
|
||||
f"- {data['nas_docs']} NAS documenten\n"
|
||||
)
|
||||
system = "Je bent Herman, co-CEO Foodlinkk. Kort, zakelijk, actionable."
|
||||
try:
|
||||
return await ollama.generate(prompt, system=system, timeout=120.0)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _save_briefing(content: str, data: dict[str, Any]) -> None:
|
||||
safe = serialize_stats(data)
|
||||
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
|
||||
(content, "herman", json.dumps(metadata)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
"herman", "herman_delegate", "briefing",
|
||||
f"CEO dagrapport {data['date']}", content[:2000],
|
||||
"completed", "dashboard", json.dumps({"stats": safe}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
|
||||
data = collect_briefing_data()
|
||||
template = build_template_report(data)
|
||||
try:
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data, template), timeout=90.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
ai_part = ""
|
||||
|
||||
if ai_part and len(ai_part.strip()) > 40:
|
||||
content = ai_part.strip() + "\n\n---\n\n" + template
|
||||
else:
|
||||
content = template + "\n\n---\n\n*AI-samenvatting niet beschikbaar (Ollama busy) — bovenstaande data is live uit je database.*"
|
||||
|
||||
_save_briefing(content, data)
|
||||
return content, serialize_stats(data)
|
||||
@@ -0,0 +1,561 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
|
||||
<style>
|
||||
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.browser-tab {
|
||||
padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
|
||||
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
|
||||
}
|
||||
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
|
||||
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
|
||||
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
|
||||
.browser-live-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) { .browser-live-row { grid-template-columns: 1fr; } }
|
||||
.browser-live-panel { min-height: 380px; }
|
||||
.browser-live-frame {
|
||||
width: 100%; min-height: 420px; background: #0a0f1a; border-radius: 10px;
|
||||
border: 1px solid rgba(148,163,184,.15); object-fit: contain;
|
||||
}
|
||||
.browser-novnc { width: 100%; height: 360px; border: 0; border-radius: 10px; background: #000; }
|
||||
.browser-preview-text {
|
||||
max-height: 220px; overflow: auto; font-size: 0.82rem; color: var(--text-muted);
|
||||
white-space: pre-wrap; background: rgba(0,0,0,.25); padding: .75rem; border-radius: 8px;
|
||||
}
|
||||
.browser-links { max-height: 160px; overflow: auto; font-size: 0.78rem; }
|
||||
.browser-status { font-size: 0.8rem; color: var(--accent-cyan); margin-top: 0.35rem; }
|
||||
.novnc-hint { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.5rem; }
|
||||
.preset-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
|
||||
.preset-chip { font-size: 0.78rem; padding: 0.25rem 0.55rem; border-radius: 999px; cursor: pointer; border: 1px solid rgba(148,163,184,.2); background: rgba(0,0,0,.2); }
|
||||
.preset-chip:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
|
||||
.job-bar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 0.85rem; margin-bottom: 1rem; border-radius: 10px;
|
||||
background: rgba(0,0,0,.25); border: 1px solid rgba(148,163,184,.12);
|
||||
}
|
||||
.job-bar.active { border-color: rgba(232,168,56,.35); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="browserPage()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>Browser & Monitor</h1>
|
||||
<p class="subtitle page-tip">Voer een URL in — Playwright + live VNC desktop openen dezelfde pagina. Gebruik <strong>Volledige extractie</strong> voor gewicht, prijs en alle zichtbare data.</p>
|
||||
</header>
|
||||
|
||||
<div class="job-bar" :class="{ active: jobRunning }">
|
||||
<strong style="font-size:0.85rem">Achtergrondtaak</strong>
|
||||
<span class="browser-status" style="margin:0" x-text="jobLabel || 'Geen actieve taak — je kunt vrij navigeren'"></span>
|
||||
<button type="button" class="btn btn-sm btn-reject" x-show="jobRunning" @click="stopJob()">⏹ Stop</button>
|
||||
<label style="font-size:0.78rem;color:var(--text-muted);margin-left:auto">
|
||||
<input type="checkbox" x-model="vncSync" @change="startPoll()" /> Screenshot = VNC scherm
|
||||
</label>
|
||||
<label style="font-size:0.78rem;color:var(--text-muted)">
|
||||
<input type="checkbox" x-model="livePoll" @change="startPoll()" /> Live poll
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snelle sites</h2>
|
||||
<p class="page-tip">Klik om URL te laden. TikTok/Instagram vereisen soms inloggen via VNC.</p>
|
||||
<div class="preset-grid">
|
||||
<span class="preset-chip" @click="loadPreset('https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/')">Bidfood Mekkafood</span>
|
||||
<span class="preset-chip" @click="loadPreset('https://www.sligro.nl/')">Sligro</span>
|
||||
<span class="preset-chip" @click="loadPreset('https://www.tiktok.com/')">TikTok</span>
|
||||
<span class="preset-chip" @click="loadPreset('https://www.instagram.com/')">Instagram</span>
|
||||
<span class="preset-chip" @click="loadPreset('https://www.cheemafood.nl/')">Cheema</span>
|
||||
</div>
|
||||
<div class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
|
||||
<input class="form-input" style="flex:2;min-width:200px" placeholder="Nieuwe site URL" x-model="newSiteUrl" />
|
||||
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="newSiteName" />
|
||||
<button type="button" class="btn btn-secondary" @click="addSiteQuick()">+ Toevoegen aan monitor</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="browser-tabs">
|
||||
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
|
||||
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
|
||||
</div>
|
||||
|
||||
<div class="browser-live-row" style="margin-bottom:1rem">
|
||||
<section class="panel browser-live-panel">
|
||||
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
|
||||
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
|
||||
</section>
|
||||
<section class="panel browser-live-panel">
|
||||
<h2>Live desktop <small class="muted">noVNC</small>
|
||||
<a href="{{ novnc_url }}" target="_blank" style="font-size:0.75rem;margin-left:0.5rem">Open apart ↗</a>
|
||||
</h2>
|
||||
<iframe class="browser-novnc" src="{{ novnc_url }}" title="Live VNC" data-clear-on-nav></iframe>
|
||||
<p class="novnc-hint">Wachtwoord: <code>Foodlinkk2026</code>. Tip: open VNC ook <a href="{{ novnc_url }}" target="_blank">in apart venster</a> — dan geen navigatie-waarschuwing bij wisselen van pagina.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'research'">
|
||||
<section class="panel browser-instruct-box">
|
||||
<h2>Interactief — leer Herman wat te klikken</h2>
|
||||
<p class="page-tip">Beschrijf in het Nederlands wat de browser moet doen. Voorbeeld: <em>Accepteren cookies, scroll naar beneden, klik op Mekkafood filter</em></p>
|
||||
<textarea class="form-input" x-model="instruction" rows="3" placeholder="Accepteren cookies → scroll 2× → klik op 'Snacks'"></textarea>
|
||||
<div class="browser-action-bar">
|
||||
<button type="button" class="btn btn-primary" @click="instructBrowse()" :disabled="loading">Uitvoeren + screenshot</button>
|
||||
<button type="button" class="btn btn-primary" @click="extractFull()" :disabled="loading">🔬 Volledige extractie</button>
|
||||
<button type="button" class="btn btn-secondary" @click="vncNavigate()" :disabled="loading">🖥 Open in VNC</button>
|
||||
<button type="button" class="btn btn-secondary" @click="exportPhoto()" :disabled="!session">📥 Foto export</button>
|
||||
<button type="button" class="btn btn-secondary" @click="runOcr()" :disabled="!session">🔍 OCR tekst</button>
|
||||
<button type="button" class="btn btn-secondary" @click="savePhotoToDb()" :disabled="!session">💾 Foto → DB</button>
|
||||
<a class="btn btn-secondary" href="{{ gradio_url }}" target="_blank" rel="noopener">🤖 Browser Use AI</a>
|
||||
</div>
|
||||
<div class="browser-steps-log" x-show="stepsLog.length" x-text="stepsLog.join(' · ')"></div>
|
||||
<div x-show="ocrChips.length" style="margin-top:0.5rem">
|
||||
<small class="muted">OCR woorden (compact)</small>
|
||||
<div class="word-chip-cloud" style="max-height:72px;margin-top:0.35rem">
|
||||
<template x-for="(w,i) in ocrChips" :key="i">
|
||||
<span class="word-chip low" x-text="w"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<form class="form-grid" @submit.prevent="browse()" style="grid-template-columns: 1fr 1fr 80px auto auto auto;">
|
||||
<input class="form-input" x-model="url" placeholder="https://..." required />
|
||||
<input class="form-input" x-model="task" placeholder="Taak (optioneel)" />
|
||||
<input class="form-input" type="number" min="1" max="12" x-model.number="scrollPages" title="Scroll pagina's voor extractie" />
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
<span x-show="!loading">Browse + VNC</span><span x-show="loading">Bezig…</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="addMonitor()">+ Monitor</button>
|
||||
</form>
|
||||
<div class="browser-status" x-text="statusText"></div>
|
||||
</section>
|
||||
|
||||
<div class="browser-layout">
|
||||
<div>
|
||||
<section class="panel" x-show="extractedItems.length">
|
||||
<h2>Geëxtraheerde data <small class="muted" x-text="'(' + extractedItems.length + ' items)'"></small></h2>
|
||||
<table class="data-table extract-table">
|
||||
<thead><tr><th>Regel / product</th><th>Gewicht</th><th>Prijs</th><th>Bron</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(it, idx) in extractedItems" :key="idx">
|
||||
<tr>
|
||||
<td x-text="it.raw"></td>
|
||||
<td x-text="it.weight || '—'"></td>
|
||||
<td x-text="it.price || '—'"></td>
|
||||
<td x-text="it.source || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel" x-show="session">
|
||||
<h2 x-text="session ? session.title : 'Resultaat'"></h2>
|
||||
<p class="page-tip"><span x-text="session ? session.final_url : ''"></span></p>
|
||||
<div class="browser-preview-text" x-text="session ? (session.content_preview || session.content_text || '') : ''"></div>
|
||||
<div class="browser-links" x-show="session && session.links && session.links.length">
|
||||
<strong>Links op pagina:</strong>
|
||||
<template x-for="lnk in (session.links || []).slice(0,15)" :key="lnk.href">
|
||||
<div><a :href="lnk.href" target="_blank" x-text="lnk.text || lnk.href"></a></div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside>
|
||||
<section class="panel">
|
||||
<h2>Recente sessies</h2>
|
||||
<template x-for="s in recent" :key="s.id">
|
||||
<div class="clickable-row" style="padding:.4rem 0;border-bottom:1px solid rgba(148,163,184,.1)" @click="loadSession(s.id)">
|
||||
<strong x-text="s.title || s.url"></strong><br/>
|
||||
<small x-text="s.created_at ? s.created_at.substring(0,16).replace('T',' ') : ''"></small>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!recent.length">Nog geen sessies.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'monitor'" x-cloak>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card clickable-kpi"><div class="kpi-label">Active sites</div><div class="kpi-value">{{ stats.active_sites }}</div></div>
|
||||
<div class="kpi-card amber clickable-kpi"><div class="kpi-label">Changes 24h</div><div class="kpi-value">{{ stats.changes_24h }}</div></div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Website toevoegen</h2>
|
||||
<form class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center" @submit.prevent="addSite">
|
||||
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
|
||||
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
|
||||
<button class="btn btn-primary" type="submit">Toevoegen</button>
|
||||
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Gemonitorde websites</h2>
|
||||
<table class="data-table"><thead><tr><th>Naam</th><th>URL</th><th>Laatst gecheckt</th><th>Acties</th></tr></thead>
|
||||
<tbody>{% for s in sites %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
|
||||
<td>{{ s.last_crawled or '—' }}</td>
|
||||
<td class="btn-group">
|
||||
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
|
||||
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
|
||||
</td></tr>{% else %}
|
||||
<tr><td colspan="4" class="empty-state">Geen sites — voeg een URL toe of klik + Monitor bij Research.</td></tr>
|
||||
{% endfor %}</tbody></table>
|
||||
</section>
|
||||
<div class="grid-2">
|
||||
<section class="panel"><h2>Wijzigingen</h2>
|
||||
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Crawl logs</h2>
|
||||
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function browserPage() {
|
||||
return {
|
||||
tab: window.location.hash === '#monitor' ? 'monitor' : 'research',
|
||||
url: {{ default_url | tojson }},
|
||||
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
|
||||
scrollPages: 6,
|
||||
newSiteUrl: '',
|
||||
newSiteName: '',
|
||||
extractedItems: [],
|
||||
stepsLog: [],
|
||||
ocrText: '',
|
||||
ocrChips: [],
|
||||
loading: false,
|
||||
livePoll: true,
|
||||
vncSync: true,
|
||||
jobRunning: false,
|
||||
jobLabel: '',
|
||||
jobAbort: null,
|
||||
session: null,
|
||||
recent: [],
|
||||
monitorForm: { url: '', name: '' },
|
||||
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
|
||||
statusText: 'Klaar — voer een URL in of klik Browse nu.',
|
||||
pollTimer: null,
|
||||
|
||||
normalizeUrl(u) {
|
||||
u = (u || '').trim();
|
||||
if (!u) return u;
|
||||
if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
|
||||
return u;
|
||||
},
|
||||
|
||||
siteNameFromUrl(u) {
|
||||
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (e) { return u; }
|
||||
},
|
||||
|
||||
setTab(t) {
|
||||
this.tab = t;
|
||||
history.replaceState(null, '', t === 'monitor' ? '/browser#monitor' : '/browser');
|
||||
},
|
||||
|
||||
init() {
|
||||
if (window.location.hash === '#monitor') this.tab = 'monitor';
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.tab = window.location.hash === '#monitor' ? 'monitor' : 'research';
|
||||
});
|
||||
this.loadRecent();
|
||||
this.startPoll();
|
||||
},
|
||||
|
||||
startPoll() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
if (!this.livePoll) return;
|
||||
const base = this.vncSync
|
||||
? '/api/admin/browser/vnc/screenshot.jpg?t='
|
||||
: '/api/admin/browser/live/screenshot.jpg?t=';
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.screenshotUrl = base + Date.now();
|
||||
}, 1500);
|
||||
this.screenshotUrl = base + Date.now();
|
||||
},
|
||||
|
||||
startJob(label) {
|
||||
if (this.jobAbort) this.jobAbort.abort();
|
||||
this.jobAbort = new AbortController();
|
||||
this.jobRunning = true;
|
||||
this.jobLabel = label;
|
||||
return this.jobAbort.signal;
|
||||
},
|
||||
|
||||
endJob(msg) {
|
||||
this.jobRunning = false;
|
||||
this.jobLabel = msg || '';
|
||||
this.jobAbort = null;
|
||||
},
|
||||
|
||||
stopJob() {
|
||||
if (this.jobAbort) {
|
||||
this.jobAbort.abort();
|
||||
Cockpit.toast('Taak geannuleerd — server kan nog kort doorlopen', 'info');
|
||||
}
|
||||
this.endJob('Gestopt door gebruiker');
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async runJob(label, fn) {
|
||||
const signal = this.startJob(label);
|
||||
this.loading = true;
|
||||
try {
|
||||
return await fn(signal);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
this.endJob('Gestopt');
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (this.jobRunning) this.endJob('Klaar');
|
||||
}
|
||||
},
|
||||
|
||||
reloadPage() {
|
||||
if (window.Cockpit && Cockpit.clearIframes) Cockpit.clearIframes();
|
||||
setTimeout(() => location.reload(), 80);
|
||||
},
|
||||
|
||||
async loadRecent() {
|
||||
try {
|
||||
const r = await Cockpit.api('/browser/sessions?limit=8');
|
||||
this.recent = r.sessions || [];
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async instructBrowse() {
|
||||
this.stepsLog = [];
|
||||
this.statusText = 'Instructies uitvoeren…';
|
||||
try {
|
||||
const r = await this.runJob('Instructies uitvoeren…', (signal) =>
|
||||
Cockpit.api('/browser/instruct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
|
||||
signal,
|
||||
})
|
||||
);
|
||||
if (!r) return;
|
||||
this.session = r.session;
|
||||
this.stepsLog = (r.session.metadata && r.session.metadata.steps) || [];
|
||||
this.statusText = 'Klaar — ' + this.stepsLog.length + ' stappen';
|
||||
if (r.session.id) {
|
||||
this.screenshotUrl = '/api/admin/browser/sessions/' + r.session.id + '/screenshot.jpg?t=' + Date.now();
|
||||
this.vncSync = false;
|
||||
}
|
||||
await this.loadRecent();
|
||||
Cockpit.toast('Instructies uitgevoerd', 'success');
|
||||
} catch (e) { this.statusText = e.message; Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
exportPhoto() {
|
||||
if (!this.session || !this.session.id) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg';
|
||||
a.download = 'foodlinkk-browser-' + this.session.id + '.jpg';
|
||||
a.click();
|
||||
},
|
||||
|
||||
async runOcr() {
|
||||
if (!this.session || !this.session.id) return;
|
||||
try {
|
||||
const r = await Cockpit.api('/browser/sessions/' + this.session.id + '/ocr');
|
||||
this.ocrText = r.ocr_text || '';
|
||||
const words = (this.ocrText.match(/[\wÀ-ÿ]{3,}/g) || []);
|
||||
const seen = new Set();
|
||||
this.ocrChips = [];
|
||||
words.forEach(w => { const k = w.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.ocrChips.push(w); } });
|
||||
if (this.ocrChips.length > 40) this.ocrChips = this.ocrChips.slice(0, 40);
|
||||
Cockpit.toast('OCR klaar', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async browse() {
|
||||
this.statusText = 'Browser + VNC starten op achtergrond…';
|
||||
try {
|
||||
const r = await this.runJob('Browse + VNC…', async (signal) => {
|
||||
const vncP = Cockpit.api('/browser/vnc-navigate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 4 }),
|
||||
signal,
|
||||
}).catch(() => null);
|
||||
const browseP = Cockpit.api('/browser/browse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: this.url, task: this.task, instruction: this.instruction, wait_seconds: 4 }),
|
||||
signal,
|
||||
});
|
||||
const [res] = await Promise.all([browseP, vncP]);
|
||||
return res;
|
||||
});
|
||||
if (!r) return;
|
||||
this.session = r.session;
|
||||
this.statusText = 'Klaar — sessie #' + r.session.id + ' (je kunt verder navigeren)';
|
||||
if (r.session.id) {
|
||||
this.vncSync = true;
|
||||
this.startPoll();
|
||||
}
|
||||
await this.loadRecent();
|
||||
Cockpit.toast('Browse gestart — VNC + database', 'success');
|
||||
} catch (e) {
|
||||
this.statusText = 'Fout: ' + e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async vncNavigate() {
|
||||
this.statusText = 'VNC opent URL…';
|
||||
try {
|
||||
const r = await this.runJob('VNC navigate…', (signal) =>
|
||||
Cockpit.api('/browser/vnc-navigate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
|
||||
signal,
|
||||
})
|
||||
);
|
||||
if (!r) return;
|
||||
this.statusText = 'VNC: ' + (r.title || r.final_url || this.url);
|
||||
this.vncSync = true;
|
||||
this.startPoll();
|
||||
Cockpit.toast('URL geopend in live desktop', 'success');
|
||||
} catch (e) {
|
||||
this.statusText = 'VNC fout: ' + e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async extractFull() {
|
||||
this.statusText = 'Extractie op achtergrond…';
|
||||
this.extractedItems = [];
|
||||
try {
|
||||
const r = await this.runJob('Volledige extractie…', (signal) =>
|
||||
Cockpit.api('/browser/extract-full', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: this.url, instruction: this.instruction, wait_seconds: 5,
|
||||
scroll_pages: this.scrollPages, also_vnc: true,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
);
|
||||
if (!r) return;
|
||||
this.session = r.session;
|
||||
this.extractedItems = r.session.extracted_items || (r.session.metadata && r.session.metadata.extracted_items) || [];
|
||||
this.statusText = 'Extractie klaar — ' + this.extractedItems.length + ' items';
|
||||
if (r.session.id) {
|
||||
this.vncSync = true;
|
||||
this.startPoll();
|
||||
}
|
||||
await this.loadRecent();
|
||||
Cockpit.toast(this.extractedItems.length + ' items geëxtraheerd', 'success');
|
||||
} catch (e) {
|
||||
this.statusText = 'Extractie fout: ' + e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
loadPreset(u) {
|
||||
this.url = u;
|
||||
this.statusText = 'Preset geladen — klik Browse + VNC of Volledige extractie';
|
||||
},
|
||||
|
||||
async addSiteQuick() {
|
||||
const u = this.normalizeUrl(this.newSiteUrl || this.url);
|
||||
if (!u) return Cockpit.toast('Geen URL', 'error');
|
||||
try {
|
||||
await Cockpit.api('/monitor/sites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
|
||||
});
|
||||
Cockpit.toast('Site toegevoegd — tab Monitor', 'success');
|
||||
this.newSiteUrl = '';
|
||||
this.newSiteName = '';
|
||||
this.setTab('monitor');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async savePhotoToDb() {
|
||||
if (!this.session || !this.session.id) return;
|
||||
try {
|
||||
const img = await fetch('/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg');
|
||||
const blob = await img.blob();
|
||||
const b64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(r.result.split(',')[1]);
|
||||
r.onerror = rej;
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
const r = await Cockpit.api('/photos/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
image_b64: b64,
|
||||
source: 'browser-session',
|
||||
filename: 'session-' + this.session.id + '.jpg',
|
||||
session_id: this.session.id,
|
||||
}),
|
||||
});
|
||||
Cockpit.toast('Foto opgeslagen in DB #' + r.photo.id, 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async loadSession(id) {
|
||||
try {
|
||||
const r = await Cockpit.api('/browser/sessions/' + id);
|
||||
this.session = r.session;
|
||||
this.url = r.session.url || this.url;
|
||||
this.extractedItems = (r.session.metadata && r.session.metadata.extracted_items) || r.session.extracted_items || [];
|
||||
this.screenshotUrl = '/api/admin/browser/sessions/' + id + '/screenshot.jpg?t=' + Date.now();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async addMonitor() {
|
||||
const u = this.normalizeUrl(this.url);
|
||||
if (!u) return Cockpit.toast('Geen URL', 'error');
|
||||
try {
|
||||
await Cockpit.api('/monitor/sites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: u, name: (this.session && this.session.title) || this.siteNameFromUrl(u) }),
|
||||
});
|
||||
Cockpit.toast('Toegevoegd aan Site Monitor', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async addSite() {
|
||||
const payload = { ...this.monitorForm, url: this.normalizeUrl(this.monitorForm.url) };
|
||||
if (!payload.url) return Cockpit.toast('URL verplicht', 'error');
|
||||
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
|
||||
try {
|
||||
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
|
||||
Cockpit.toast('Site toegevoegd', 'success');
|
||||
this.reloadPage();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async crawlAll() {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
|
||||
},
|
||||
async crawlOne(id) {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
|
||||
},
|
||||
async toggleSite(id) {
|
||||
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
|
||||
this.reloadPage();
|
||||
},
|
||||
async removeSite(id) {
|
||||
if (!confirm('Website permanent verwijderen uit monitoring?')) return;
|
||||
await Cockpit.api('/monitor/sites/' + id + '?hard=true', { method: 'DELETE' });
|
||||
Cockpit.toast('Verwijderd', 'success'); this.reloadPage();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="clientsCrud()">
|
||||
<header class="page-header"><h1>Clients</h1>
|
||||
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
|
||||
<button class="btn btn-primary" @click="openModal()">Add client</button>
|
||||
<div class="kanban">
|
||||
{% set stages = ['intake','discovery','proposal','active','churned'] %}
|
||||
{% for st in stages %}
|
||||
<div class="kanban-col"><h3>{{ st }}</h3>
|
||||
{% for c in clients if c.stage == st %}
|
||||
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
|
||||
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
|
||||
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
|
||||
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
|
||||
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
|
||||
<input class="form-input" placeholder="Name" x-model="form.name" />
|
||||
<input class="form-input" placeholder="Email" x-model="form.email" />
|
||||
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
|
||||
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
|
||||
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function clientsCrud() {
|
||||
return {
|
||||
modal: false, drawer: false, detail: '',
|
||||
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
|
||||
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
|
||||
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
|
||||
async save() {
|
||||
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
|
||||
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
},
|
||||
async setStage(id, stage, name, email, notes) {
|
||||
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
|
||||
Cockpit.toast('Stage updated', 'success'); location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
window.Cockpit = (function () {
|
||||
const API = '/api/admin';
|
||||
|
||||
function toast(message, type) {
|
||||
type = type || 'info';
|
||||
let root = document.getElementById('cockpit-toasts');
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = 'cockpit-toasts';
|
||||
root.className = 'toast-container';
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast-' + type;
|
||||
el.textContent = message;
|
||||
root.appendChild(el);
|
||||
setTimeout(function () { el.classList.add('toast-out'); setTimeout(function () { el.remove(); }, 300); }, 3500);
|
||||
}
|
||||
|
||||
function clearEmbeddedIframes() {
|
||||
document.querySelectorAll('iframe.browser-novnc, iframe[data-clear-on-nav]').forEach(function (f) {
|
||||
try { f.src = 'about:blank'; } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
options = options || {};
|
||||
const url = path.startsWith('http') || path.startsWith('/api/') ? path : API + path;
|
||||
const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
|
||||
const fetchOpts = Object.assign({}, options, { headers: headers });
|
||||
if (options.signal) fetchOpts.signal = options.signal;
|
||||
const res = await fetch(url, fetchOpts);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { data = null; }
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.detail || data.message)) || res.statusText;
|
||||
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function confirmDelete(message) {
|
||||
return window.confirm(message || 'Delete this item?');
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
document.body.classList.add('drawer-open');
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.add('open');
|
||||
}
|
||||
|
||||
function closeDrawer(id) {
|
||||
document.body.classList.remove('drawer-open');
|
||||
if (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.remove('open');
|
||||
}
|
||||
document.querySelectorAll('.drawer.open').forEach(function (d) { d.classList.remove('open'); });
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeDrawer();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var link = e.target.closest('a[href]');
|
||||
if (!link || link.target === '_blank' || link.hasAttribute('download')) return;
|
||||
var href = link.getAttribute('href') || '';
|
||||
if (!href || href.charAt(0) === '#') return;
|
||||
if (href.indexOf('6080') !== -1 || href.indexOf('7788') !== -1) return;
|
||||
if (href.charAt(0) === '/' || href.indexOf('http') === 0) clearEmbeddedIframes();
|
||||
}, true);
|
||||
|
||||
return { toast: toast, api: request, confirmDelete: confirmDelete, openDrawer: openDrawer, closeDrawer: closeDrawer, clearIframes: clearEmbeddedIframes };
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=8" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo">
|
||||
|
||||
<div class="herman-hero">
|
||||
<h1>Herman · Command Center <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">CEO overzicht · klanten · briefing · retail · alles op een rij</p>
|
||||
</div>
|
||||
|
||||
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
|
||||
<div class="briefing-loading-overlay" x-show="loading" x-cloak>
|
||||
<div class="loading-dots"><span></span><span></span><span></span></div>
|
||||
<p x-text="loadingMsg"></p>
|
||||
</div>
|
||||
|
||||
<header class="herman-briefing-header">
|
||||
<div>
|
||||
<h2>Dagelijkse briefing</h2>
|
||||
<div class="briefing-meta" id="briefing-meta"></div>
|
||||
</div>
|
||||
<div class="herman-briefing-actions">
|
||||
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
|
||||
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
|
||||
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
|
||||
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
|
||||
<a href="/analytics" class="btn">Analytics</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="briefing-dashboard" class="hm-dash-layout">
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's</h3>
|
||||
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database · geen RSS — zie Marketing Hub</small></div>
|
||||
<div id="briefing-executive-summary"></div>
|
||||
</div>
|
||||
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<h3 class="hm-section-title">Herman briefing</h3>
|
||||
<div class="briefing-summary-grid">
|
||||
<div class="briefing-card summary">
|
||||
<h4>Samenvatting</h4>
|
||||
<p id="briefing-summary-text" class="briefing-typewriter"></p>
|
||||
</div>
|
||||
<div class="briefing-horizon-col">
|
||||
<div class="horizon-card short">
|
||||
<h4>⚡ Korte termijn</h4>
|
||||
<ul id="briefing-actions-list"></ul>
|
||||
</div>
|
||||
<div class="horizon-card long">
|
||||
<h4>🎯 Lange termijn</h4>
|
||||
<ul id="briefing-longterm-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section">
|
||||
<h3 class="hm-section-title">Retail operatie</h3>
|
||||
<div class="hm-dash-two-col">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Top retail kansen</h4><small><a href="/retail">Retail 360 →</a></small></div>
|
||||
<div id="briefing-retail"></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
|
||||
<div id="briefing-milestones"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
|
||||
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
|
||||
<div id="briefing-food-highlights" class="hm-highlight-scroll"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<h3 class="hm-section-title">Data & analytics</h3>
|
||||
<div class="hm-neo-charts">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hub-grid hm-dash-feed">
|
||||
<section class="panel">
|
||||
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
|
||||
<div id="agent-feed-live">
|
||||
{% for ev in agent_feed[:12] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
|
||||
{{ ev.title or ev.event_type }}
|
||||
</div>
|
||||
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Wacht op OK</h2>
|
||||
{% for item in approvals[:8] %}
|
||||
<div class="approval-item"><span class="badge">{{ item.agent_name }}</span> {{ item.title or item.event_type }}</div>
|
||||
{% else %}<p class="empty-state">Geen goedkeuringen 🎉</p>{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/briefing-charts.js?v=8"></script>
|
||||
<script>
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
function dashboardBriefing() {
|
||||
return {
|
||||
loading: false, loadingMsg: 'Herman werkt…',
|
||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||
_pollStop: null, _wsStop: null,
|
||||
async init() {
|
||||
await this.refreshLive(false);
|
||||
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refreshLive(false), 30000);
|
||||
this._wsStop = CockpitLive.connectFeed((data) => {
|
||||
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
|
||||
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
||||
});
|
||||
},
|
||||
renderClientsMini(stats) {
|
||||
const el = document.getElementById('briefing-clients-mini');
|
||||
if (!el) return;
|
||||
const active = stats.clients_active || 0, total = stats.clients_total || stats.clients || 0;
|
||||
el.innerHTML = '<a href="/clients" class="beurs-mini-chip up"><strong>' + active + '</strong> actief</a>' +
|
||||
'<a href="/clients" class="beurs-mini-chip"><strong>' + total + '</strong> totaal klanten</a>' +
|
||||
'<a href="/retail" class="beurs-mini-chip"><strong>' + (stats.crm_partnerships||0) + '</strong> partnerships</a>' +
|
||||
'<a href="/marketing?tab=reclame" class="beurs-mini-chip"><strong>' + (stats.promo_campaigns||0) + '</strong> promo\'s</a>';
|
||||
},
|
||||
async refreshLive(toast) {
|
||||
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
|
||||
try {
|
||||
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
||||
if (r.stats) {
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats);
|
||||
this.renderClientsMini(r.stats);
|
||||
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
||||
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async generate() {
|
||||
this.loading = true;
|
||||
this.loadingMsg = 'Live data laden…';
|
||||
await this.refreshLive(false);
|
||||
this.loadingMsg = 'Herman schrijft dagrapport…';
|
||||
try {
|
||||
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
|
||||
this.content = r.content || '';
|
||||
this.createdAt = r.generated_at || new Date().toISOString();
|
||||
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt);
|
||||
this.renderClientsMini(r.stats || {});
|
||||
Cockpit.toast('Dagrapport klaar!', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,162 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services.briefing import collect_briefing_data, serialize_stats
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
|
||||
def _safe_count(table: str, where: str = "") -> int:
|
||||
try:
|
||||
clause = f" WHERE {where}" if where else ""
|
||||
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
|
||||
return int(row["c"]) if row else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_sum(table: str, column: str, where: str = "") -> float:
|
||||
try:
|
||||
clause = f" WHERE {where}" if where else ""
|
||||
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
|
||||
return float(row["total"]) if row else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _briefing_payload(briefing: dict | None) -> dict:
|
||||
"""Always use live DB stats; briefing text may be cached."""
|
||||
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
|
||||
if not briefing:
|
||||
return payload
|
||||
|
||||
payload["content"] = briefing.get("content")
|
||||
payload["created_at"] = briefing.get("created_at")
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def dashboard(request: Request):
|
||||
kpis = {
|
||||
"deals_count": _safe_count("deals"),
|
||||
"clients_count": _safe_count("clients"),
|
||||
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
|
||||
"pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
|
||||
"browser_sessions_24h": 0,
|
||||
"monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
|
||||
"supermarkets_count": _safe_count("supermarkets"),
|
||||
"crm_partnerships": _safe_count("client_supermarket_links", "partnership_status = 'active'"),
|
||||
}
|
||||
|
||||
briefing = None
|
||||
try:
|
||||
briefing = fetch_one(
|
||||
"SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if briefing and briefing.get("created_at"):
|
||||
briefing["created_at"] = briefing["created_at"].isoformat()
|
||||
except Exception:
|
||||
briefing = None
|
||||
|
||||
agent_feed: list = []
|
||||
try:
|
||||
agent_feed = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, created_at
|
||||
FROM agent_events ORDER BY created_at DESC LIMIT 25
|
||||
"""
|
||||
)
|
||||
for ev in agent_feed:
|
||||
if ev.get("created_at"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
except Exception:
|
||||
agent_feed = []
|
||||
|
||||
approvals: list = []
|
||||
try:
|
||||
approvals = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, created_at
|
||||
FROM agent_events WHERE status = 'needs_approval'
|
||||
ORDER BY created_at ASC LIMIT 25
|
||||
"""
|
||||
)
|
||||
for ev in approvals:
|
||||
if ev.get("created_at"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
except Exception:
|
||||
approvals = []
|
||||
|
||||
browser_sessions: list = []
|
||||
try:
|
||||
browser_sessions = fetch_all(
|
||||
"""
|
||||
SELECT id, url, final_url, title, task, status, created_at,
|
||||
LEFT(content_text, 300) AS preview
|
||||
FROM browser_sessions
|
||||
ORDER BY created_at DESC LIMIT 8
|
||||
"""
|
||||
)
|
||||
kpis["browser_sessions_24h"] = _safe_count(
|
||||
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
|
||||
)
|
||||
for s in browser_sessions:
|
||||
if s.get("created_at"):
|
||||
s["created_at"] = s["created_at"].isoformat()
|
||||
except Exception:
|
||||
browser_sessions = []
|
||||
|
||||
monitor_sites: list = []
|
||||
monitor_changes: list = []
|
||||
try:
|
||||
monitor_sites = fetch_all(
|
||||
"""
|
||||
SELECT id, name, url, last_title, last_crawled, is_active
|
||||
FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
|
||||
"""
|
||||
)
|
||||
for s in monitor_sites:
|
||||
if s.get("last_crawled"):
|
||||
s["last_crawled"] = s["last_crawled"].isoformat()
|
||||
monitor_changes = fetch_all(
|
||||
"""
|
||||
SELECT pc.id, pc.changed_at, ms.name, ms.url
|
||||
FROM page_changes pc
|
||||
JOIN monitored_sites ms ON ms.id = pc.site_id
|
||||
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY pc.changed_at DESC LIMIT 10
|
||||
"""
|
||||
)
|
||||
for c in monitor_changes:
|
||||
if c.get("changed_at"):
|
||||
c["changed_at"] = c["changed_at"].isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Herman · Command Center",
|
||||
"kpis": kpis,
|
||||
"briefing": briefing,
|
||||
"briefing_payload": _briefing_payload(briefing),
|
||||
"agent_feed": agent_feed,
|
||||
"approvals": approvals,
|
||||
"browser_sessions": browser_sessions,
|
||||
"monitor_sites": monitor_sites,
|
||||
"monitor_changes": monitor_changes,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marketing-redirect")
|
||||
async def marketing_redirect():
|
||||
return RedirectResponse(url="/marketing", status_code=302)
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="dealsCrud()">
|
||||
<header class="page-header"><h1>Deals</h1><p class="subtitle page-tip">Pipeline deals with full CRUD — click row to edit.</p></header>
|
||||
<button class="btn btn-primary" @click="openNew()">Add deal</button>
|
||||
<table class="data-table"><thead><tr><th>Title</th><th>Client</th><th>Value</th><th>Stage</th><th></th></tr></thead>
|
||||
<tbody>{% for d in deals %}
|
||||
<tr class="clickable-row" @click="editRow({{ d.id }}, '{{ d.title|e }}', {{ d.value or 0 }}, '{{ d.stage|e }}')">
|
||||
<td>{{ d.title }}</td><td>{{ d.client_name or '—' }}</td><td>€{{ d.value }}</td><td>{{ d.stage }}</td>
|
||||
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ d.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
|
||||
<h3>Deal</h3><input class="form-input" x-model="form.title" /><input type="number" class="form-input" x-model="form.value" /><input class="form-input" x-model="form.stage" />
|
||||
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button></div></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function dealsCrud() {
|
||||
return {
|
||||
show: false, id: null, form: { title: '', value: 0, stage: 'lead', client_id: null },
|
||||
openNew() { this.id = null; this.form = { title: '', value: 0, stage: 'lead', client_id: null }; this.show = true; },
|
||||
editRow(id, title, value, stage) { this.id = id; this.form = { title, value, stage, client_id: null }; this.show = true; },
|
||||
async save() {
|
||||
const body = { ...this.form, value: parseFloat(this.form.value) };
|
||||
if (this.id) await Cockpit.api('/deals/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/deals', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
},
|
||||
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/deals/' + id, { method: 'DELETE' }); location.reload(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,383 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
|
||||
.doc-chart-panel { height: 140px; }
|
||||
.doc-chart-panel canvas { max-height: 110px !important; }
|
||||
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
|
||||
.photo-card { border:1px solid rgba(148,163,184,.15); border-radius:10px; overflow:hidden; background:rgba(0,0,0,.2); }
|
||||
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
|
||||
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
|
||||
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
|
||||
.detection-wrap canvas { position:absolute; left:0; top:0; pointer-events:none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="documentsDash()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>Documents & Foto's — interactief dashboard</h1>
|
||||
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p>
|
||||
</header>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card clickable-kpi" @click="filterSentiment=''"><div class="kpi-label">Documenten</div><div class="kpi-value" x-text="filteredDocs.length"></div></div>
|
||||
<div class="kpi-card purple clickable-kpi"><div class="kpi-label">Totaal woorden</div><div class="kpi-value">{{ summary.total_words }}</div></div>
|
||||
<div class="kpi-card green clickable-kpi"><div class="kpi-label">Foto's in DB</div><div class="kpi-value" x-text="photos.length"></div></div>
|
||||
<div class="kpi-card orange clickable-kpi"><div class="kpi-label">Gem. sentiment</div><div class="kpi-value">{{ summary.avg_sentiment }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="doc-dash-filters panel">
|
||||
<input type="search" class="form-input" style="min-width:180px" placeholder="Zoek bestand..." x-model="filterQuery" @input="applyFilters()" />
|
||||
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
|
||||
<option value="">Alle sentiment</option>
|
||||
<option value="positive">Positief</option>
|
||||
<option value="neutral">Neutraal</option>
|
||||
<option value="negative">Negatief</option>
|
||||
</select>
|
||||
<select class="form-input" x-model="filterType" @change="applyFilters()">
|
||||
<option value="">Alle types</option>
|
||||
<option value="presentation">Presentatie (pptx)</option>
|
||||
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
|
||||
</select>
|
||||
<label style="font-size:0.8rem;color:var(--text-muted)"><input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live refresh (15s)</label>
|
||||
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">↻ Foto's</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-top:1rem">
|
||||
<section class="panel doc-chart-panel">
|
||||
<h2>Top woorden <small class="muted">klik = filter</small></h2>
|
||||
<canvas id="wordsChart" height="110"></canvas>
|
||||
</section>
|
||||
<section class="panel doc-chart-panel">
|
||||
<h2>Sentiment <small class="muted">klik = filter</small></h2>
|
||||
<canvas id="sentimentChart" height="110"></canvas>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" style="margin-top:1rem" x-data="wordSearch()" x-init="init()">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||
<h2 style="margin:0">Woord zoeken</h2>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<input type="search" x-model="query" @keydown.enter="search()" placeholder="Zoek lemma..." class="form-input" style="min-width:220px" />
|
||||
<label style="display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:var(--text-muted)">
|
||||
<input type="checkbox" x-model="includeStopwords" @change="search()" /> stopwoorden
|
||||
</label>
|
||||
<button class="btn btn-primary" @click="search()">Zoek</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="data-table" style="margin-top:1rem">
|
||||
<thead><tr><th>Lemma</th><th>Token</th><th>Totaal</th><th>Documenten</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-if="loading"><tr><td colspan="4">Laden...</td></tr></template>
|
||||
<template x-for="row in results" :key="row.lemma">
|
||||
<tr class="clickable-row" @click="$root.filterQuery=row.lemma; $root.applyFilters()">
|
||||
<td x-text="row.lemma"></td>
|
||||
<td x-text="row.token || row.sample_token || '—'"></td>
|
||||
<td x-text="row.total_count"></td>
|
||||
<td x-text="row.document_count || row.doc_count"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top:1rem">
|
||||
<h2>Documenten op NAS <span class="muted" x-text="'(' + filteredDocs.length + ')'"></span></h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Bestand</th><th>Pad</th><th>Type</th><th>Woorden</th><th>Sentiment</th><th>Score</th><th>Geanalyseerd</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="doc in filteredDocs" :key="doc.storage_path">
|
||||
<tr>
|
||||
<td x-text="doc.filename"></td>
|
||||
<td class="muted" style="font-size:0.75rem" x-text="doc.storage_path"></td>
|
||||
<td x-text="doc.doc_type"></td>
|
||||
<td x-text="doc.word_count"></td>
|
||||
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
|
||||
<td x-text="doc.sentiment_compound != null ? Number(doc.sentiment_compound).toFixed(3) : '—'"></td>
|
||||
<td x-text="(doc.analyzed_at || doc.modified_at || '').substring(0,16)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!filteredDocs.length"><td colspan="7" class="empty-state">Geen documenten voor dit filter.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top:1rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem">
|
||||
<h2 style="margin:0">Foto's — OCR & detectie</h2>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:220px" />
|
||||
<button class="btn btn-secondary btn-sm" @click="loadNasImages()">NAS share foto's</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="page-tip">Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).</p>
|
||||
<div class="photo-grid" style="margin-top:1rem">
|
||||
<template x-for="p in photos" :key="p.id">
|
||||
<div class="photo-card clickable-row" @click="openPhoto(p.id)">
|
||||
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
|
||||
<div class="photo-card-body">
|
||||
<strong x-text="'#' + p.id + ' ' + (p.source || '')"></strong><br/>
|
||||
<span x-text="(p.ocr_preview || '').substring(0,80)"></span><br/>
|
||||
<small x-text="(p.box_count || 0) + ' boxes · ' + (p.item_count || 0) + ' items'"></small>
|
||||
<button class="btn btn-sm btn-secondary" style="margin-top:0.35rem" @click.stop="exportPhoto(p.id)">📥 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!photos.length">Nog geen foto's — upload hier of stuur via Telegram.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
|
||||
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
|
||||
<div class="detection-wrap">
|
||||
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
|
||||
<canvas id="detectionCanvas"></canvas>
|
||||
</div>
|
||||
<pre class="browser-preview-text" style="margin-top:0.75rem;max-height:160px" x-text="activeOcr"></pre>
|
||||
<table class="data-table" x-show="activeItems.length" style="margin-top:0.5rem;font-size:0.78rem">
|
||||
<thead><tr><th>Geëxtraheerd</th><th>Gewicht</th><th>Prijs</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(it,i) in activeItems" :key="i">
|
||||
<tr><td x-text="it.raw"></td><td x-text="it.weight||'—'"></td><td x-text="it.price||'—'"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const topWords = {{ top_words | tojson }};
|
||||
const summary = {{ summary | tojson }};
|
||||
const allDocuments = {{ documents | tojson }};
|
||||
let shareMerged = false;
|
||||
let wordsChartInst = null;
|
||||
let sentimentChartInst = null;
|
||||
|
||||
function documentsDash() {
|
||||
return {
|
||||
filterQuery: '',
|
||||
filterSentiment: '',
|
||||
filterType: '',
|
||||
filteredDocs: allDocuments,
|
||||
docTypes: [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))],
|
||||
photos: [],
|
||||
activePhoto: null,
|
||||
activeOcr: '',
|
||||
activeItems: [],
|
||||
detections: [],
|
||||
liveRefresh: false,
|
||||
liveTimer: null,
|
||||
|
||||
init() {
|
||||
this.applyFilters();
|
||||
this.renderCharts();
|
||||
this.loadPhotos();
|
||||
this.refreshShare(false);
|
||||
},
|
||||
|
||||
mergeShareItems(items) {
|
||||
const byPath = new Map(allDocuments.map(d => [d.storage_path, d]));
|
||||
(items || []).forEach(f => {
|
||||
const path = f.path || f.storage_path;
|
||||
if (!path) return;
|
||||
if (byPath.has(path)) {
|
||||
const ex = byPath.get(path);
|
||||
if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
|
||||
return;
|
||||
}
|
||||
const row = {
|
||||
filename: f.filename || path.split('/').pop(),
|
||||
storage_path: path,
|
||||
doc_type: f.doc_type || 'general',
|
||||
word_count: f.word_count || 0,
|
||||
sentiment_label: f.sentiment_label || 'neutral',
|
||||
sentiment_compound: f.sentiment_compound || 0,
|
||||
analyzed_at: f.analyzed_at || '',
|
||||
modified_at: f.modified_at || '',
|
||||
from_share: !f.analyzed_at,
|
||||
};
|
||||
allDocuments.push(row);
|
||||
byPath.set(path, row);
|
||||
});
|
||||
this.docTypes = [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))];
|
||||
this.applyFilters();
|
||||
},
|
||||
|
||||
async refreshShare(showToast) {
|
||||
try {
|
||||
if (showToast !== false) Cockpit.toast('Share scannen…', 'info');
|
||||
await fetch('/api/admin/documents/trigger-scan', { method: 'POST' });
|
||||
const r = await fetch('/api/admin/documents/share-files?limit=300');
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
this.mergeShareItems(data.items || []);
|
||||
if (showToast !== false) Cockpit.toast((data.total || 0) + ' bestanden op share', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
if (showToast !== false) Cockpit.toast('Share scan mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
const q = this.filterQuery.toLowerCase();
|
||||
this.filteredDocs = allDocuments.filter(d => {
|
||||
if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
|
||||
if (this.filterType && d.doc_type !== this.filterType) return false;
|
||||
if (q && !(d.filename || '').toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
renderCharts() {
|
||||
if (wordsChartInst) wordsChartInst.destroy();
|
||||
if (sentimentChartInst) sentimentChartInst.destroy();
|
||||
const dash = this;
|
||||
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: topWords.slice(0, 10).map(w => w.lemma),
|
||||
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
|
||||
backgroundColor: 'rgba(232,168,56,.75)', borderWidth: 0 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
onClick: (_, els) => { if (els[0]) { dash.filterQuery = topWords[els[0].index].lemma; dash.applyFilters(); } },
|
||||
scales: { y: { beginAtZero: true, ticks: { color: '#9aa4b2', font: { size: 9 } } },
|
||||
x: { ticks: { color: '#9aa4b2', font: { size: 9 } } } }
|
||||
}
|
||||
});
|
||||
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Positief', 'Neutraal', 'Negatief'],
|
||||
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
|
||||
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#cbd5e1', font: { size: 9 } } } },
|
||||
onClick: (_, els) => {
|
||||
if (!els[0]) return;
|
||||
dash.filterSentiment = ['positive','neutral','negative'][els[0].index];
|
||||
dash.applyFilters();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async loadPhotos() {
|
||||
try {
|
||||
const r = await Cockpit.api('/photos?limit=40');
|
||||
this.photos = r.photos || [];
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
toggleLive() {
|
||||
if (this.liveTimer) clearInterval(this.liveTimer);
|
||||
if (this.liveRefresh) {
|
||||
this.liveTimer = setInterval(() => {
|
||||
this.loadPhotos();
|
||||
this.refreshShare(false);
|
||||
}, 15000);
|
||||
}
|
||||
},
|
||||
|
||||
async openPhoto(id) {
|
||||
this.activePhoto = id;
|
||||
try {
|
||||
const r = await Cockpit.api('/photos/' + id + '/detections');
|
||||
this.detections = r.detections || [];
|
||||
this.activeItems = r.extracted_items || [];
|
||||
this.activeOcr = r.ocr_text || '';
|
||||
setTimeout(() => this.drawDetections(), 200);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
drawDetections() {
|
||||
const img = document.getElementById('detectionImg');
|
||||
const canvas = document.getElementById('detectionCanvas');
|
||||
if (!img || !canvas || !this.detections.length) return;
|
||||
canvas.width = img.clientWidth;
|
||||
canvas.height = img.clientHeight;
|
||||
const sx = img.clientWidth / (img.naturalWidth || 1);
|
||||
const sy = img.clientHeight / (img.naturalHeight || 1);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = '#e8a838';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '9px sans-serif';
|
||||
this.detections.slice(0, 150).forEach(b => {
|
||||
ctx.strokeRect(b.x * sx, b.y * sy, b.w * sx, b.h * sy);
|
||||
if (b.text && b.text.length < 12) ctx.fillText(b.text, b.x * sx, b.y * sy - 2);
|
||||
});
|
||||
},
|
||||
|
||||
exportPhoto(id) {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/admin/photos/' + id + '/image.jpg';
|
||||
a.download = 'foodlinkk-photo-' + id + '.jpg';
|
||||
a.click();
|
||||
},
|
||||
|
||||
async uploadPhoto(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file) return;
|
||||
const b64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(r.result.split(',')[1]);
|
||||
r.onerror = rej;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
try {
|
||||
await Cockpit.api('/photos/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
|
||||
});
|
||||
Cockpit.toast('Foto geanalyseerd en opgeslagen', 'success');
|
||||
await this.loadPhotos();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
ev.target.value = '';
|
||||
},
|
||||
|
||||
async loadNasImages() {
|
||||
try {
|
||||
const r = await fetch('/api/admin/documents/nas-images?limit=10');
|
||||
const data = await r.json();
|
||||
if (!data.items || !data.items.length) return Cockpit.toast('Geen afbeeldingen in NAS share', 'info');
|
||||
Cockpit.toast(data.items.length + ' NAS afbeeldingen gevonden — analyseer via upload of Telegram', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wordSearch() {
|
||||
return {
|
||||
query: '', includeStopwords: false, results: topWords.slice(0, 20), loading: false,
|
||||
async search() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
|
||||
if (this.query.trim()) params.set('q', this.query.trim());
|
||||
const res = await fetch('/api/admin/documents/words?' + params);
|
||||
this.results = (await res.json()).items || [];
|
||||
} catch (e) { Cockpit.toast('Woord zoeken mislukt', 'error'); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
init() { this.search(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
||||
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
||||
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Herman dashboard — Telegram-tab style motion */
|
||||
|
||||
@keyframes tg-ripple {
|
||||
0% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.5); }
|
||||
70% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 14px rgba(56, 189, 248, 0); }
|
||||
100% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); }
|
||||
}
|
||||
|
||||
@keyframes tg-orbit {
|
||||
0% { transform: rotate(0deg) translateX(4px) rotate(0deg); }
|
||||
100% { transform: rotate(360deg) translateX(4px) rotate(-360deg); }
|
||||
}
|
||||
|
||||
@keyframes tg-gradient-flow {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes tg-breathe {
|
||||
0%, 100% { opacity: 0.85; filter: brightness(1); }
|
||||
50% { opacity: 1; filter: brightness(1.15); }
|
||||
}
|
||||
|
||||
@keyframes spin-dots {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.herman-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.herman-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%; left: -50%;
|
||||
width: 200%; height: 200%;
|
||||
background: radial-gradient(circle at 30% 40%, rgba(56,189,248,0.12) 0%, transparent 45%),
|
||||
radial-gradient(circle at 70% 60%, rgba(192,132,252,0.1) 0%, transparent 40%);
|
||||
animation: tg-gradient-flow 8s ease infinite;
|
||||
background-size: 200% 200%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.herman-briefing.pulse-panel {
|
||||
animation: tg-ripple 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.briefing-card, .briefing-chart-panel, .briefing-stat {
|
||||
animation: tg-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
.briefing-card:nth-child(2) { animation-delay: 0.5s; }
|
||||
.briefing-card:nth-child(3) { animation-delay: 1s; }
|
||||
|
||||
.hub-kpi-row .kpi-card {
|
||||
animation: tg-ripple 3.5s ease-in-out infinite;
|
||||
}
|
||||
.hub-kpi-row .kpi-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.hub-kpi-row .kpi-card:nth-child(3) { animation-delay: 0.8s; }
|
||||
.hub-kpi-row .kpi-card:nth-child(4) { animation-delay: 1.2s; }
|
||||
.hub-kpi-row .kpi-card:nth-child(5) { animation-delay: 1.6s; }
|
||||
|
||||
.live-badge::before { animation: tg-orbit 2s linear infinite, pulse-dot 1.2s ease-in-out infinite; }
|
||||
|
||||
.briefing-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 14, 20, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
z-index: 20;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.briefing-loading-overlay .loading-dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.briefing-loading-overlay .loading-dots span {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--pulse-blue, #38bdf8);
|
||||
animation: spin-dots 1.2s ease-in-out infinite;
|
||||
}
|
||||
.briefing-loading-overlay .loading-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.briefing-loading-overlay .loading-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
.briefing-loading-overlay p { color: var(--text-primary); font-size: 1rem; margin: 0; }
|
||||
|
||||
.herman-briefing { position: relative; }
|
||||
|
||||
.retail-highlight {
|
||||
animation: fade-up 0.4s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
padding: 0.4rem 0.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.retail-highlight:hover {
|
||||
background: rgba(56,189,248,0.1);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.feed-card { animation: tg-breathe 5s ease-in-out infinite; }
|
||||
|
||||
.count-up { display: inline-block; transition: color 0.3s; }
|
||||
|
||||
.section-fill:not(:empty) {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.briefing-card h3, .horizon-card h3 {
|
||||
text-shadow: 0 0 20px rgba(56,189,248,0.25);
|
||||
}
|
||||
|
||||
.topnav-herman.active, .topnav a.active {
|
||||
animation: pulse-ring 2.5s infinite;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="hermanChat()" class="chat-layout">
|
||||
<header class="page-header">
|
||||
<h1>Herman</h1>
|
||||
<p class="subtitle page-tip">Co-CEO orchestrator — routes your message to specialist agents.</p>
|
||||
</header>
|
||||
|
||||
<div class="card card-compact" style="margin-bottom:1rem;">
|
||||
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2>
|
||||
<p class="page-tip" style="margin:0 0 .75rem;">Beschrijf wat je wilt zien. Op CPU duurt dit 1–3 minuten.</p>
|
||||
<form class="chat-input" @submit.prevent="generateImage">
|
||||
<input class="form-input" x-model="imagePrompt" placeholder="bijv. halal kipnuggets productfoto, studio licht" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="generating">
|
||||
<span x-show="!generating">Genereer</span>
|
||||
<span x-show="generating">Bezig…</span>
|
||||
</button>
|
||||
</form>
|
||||
<div x-show="generating" class="page-tip" style="margin-top:.5rem;">Ik ga even de gegevens ophalen — afbeelding wordt gemaakt…</div>
|
||||
<div x-show="lastImage" style="margin-top:1rem;">
|
||||
<img :src="lastImage" alt="Gegenereerde afbeelding" style="max-width:100%;border-radius:8px;border:1px solid var(--border);" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" id="chat-log">
|
||||
{% if user_message %}<div class="chat-msg user"><p>{{ user_message }}</p></div>{% endif %}
|
||||
{% if last_reply %}<div class="chat-msg agent"><span class="badge">{{ last_agent }}</span><p>{{ last_reply }}</p></div>{% endif %}
|
||||
{% for h in history[:10] %}
|
||||
<div class="chat-msg agent"><small>{{ h.created_at }}</small><p>{{ h.title or h.body }}</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form class="chat-input" @submit.prevent="send">
|
||||
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak foto: product op witte achtergrond)" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="loading">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
function hermanChat() {
|
||||
return {
|
||||
message: '',
|
||||
imagePrompt: '',
|
||||
loading: false,
|
||||
generating: false,
|
||||
lastImage: '',
|
||||
async send() {
|
||||
if (!this.message.trim()) return;
|
||||
this.loading = true;
|
||||
const msg = this.message;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify({ message: msg }) });
|
||||
const log = document.getElementById('chat-log');
|
||||
log.innerHTML += '<div class="chat-msg user"><p>' + esc(msg) + '</p></div>';
|
||||
let badge = esc(r.agent_label || 'Herman');
|
||||
if (r.delegated_agents && r.delegated_agents.length > 1) {
|
||||
badge += ' · ' + esc(r.delegated_agents.join(', '));
|
||||
}
|
||||
let html = '<div class="chat-msg agent"><span class="badge">' + badge + '</span><p>' + esc(r.reply || '') + '</p>';
|
||||
if (r.image_url) {
|
||||
html += '<img src="' + r.image_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />';
|
||||
this.lastImage = r.image_url;
|
||||
}
|
||||
html += '</div>';
|
||||
log.innerHTML += html;
|
||||
this.message = '';
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
async generateImage() {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
this.generating = true;
|
||||
this.lastImage = '';
|
||||
try {
|
||||
const r = await Cockpit.api('/api/ai/generate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 })
|
||||
});
|
||||
if (r.proxy_url) {
|
||||
this.lastImage = r.proxy_url;
|
||||
const log = document.getElementById('chat-log');
|
||||
log.innerHTML += '<div class="chat-msg agent"><span class="badge">Design</span><p>Afbeelding klaar: ' + esc(this.imagePrompt) + '</p><img src="' + r.proxy_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" /></div>';
|
||||
}
|
||||
Cockpit.toast('Afbeelding gegenereerd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.generating = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hermes · Telegram Command Center{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="hermesControl()" x-init="init()" class="hermes-shell hm-neo">
|
||||
|
||||
<header class="hm-neo-header page-header">
|
||||
<h1>Hermes — Telegram Command Center</h1>
|
||||
<p class="hm-sub">Live feed · 4 PA browsers · second brain · volledige controle</p>
|
||||
</header>
|
||||
|
||||
<!-- Neo KPI row -->
|
||||
<div class="hm-neo-kpi-row">
|
||||
<div class="hm-neo-kpi" :class="{ highlight: tab === 'feed' }">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:72%"><div class="hm-neo-ring-inner">💬</div></div>
|
||||
<div><div class="hm-neo-kpi-label">Conversaties</div><div class="hm-neo-kpi-sub">Telegram chats</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.conversations">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:85%"><div class="hm-neo-ring-inner">📨</div></div>
|
||||
<div><div class="hm-neo-kpi-label">Berichten</div><div class="hm-neo-kpi-sub">in + out</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.messages">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:60%"><div class="hm-neo-ring-inner">🕸</div></div>
|
||||
<div><div class="hm-neo-kpi-label">Relaties</div><div class="hm-neo-kpi-sub">graph edges</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.edges">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="hm-neo-ring" style="--ring-color:#a855f7;--ring-pct:55%"><div class="hm-neo-ring-inner">🧠</div></div>
|
||||
<div><div class="hm-neo-kpi-label">Vectors</div><div class="hm-neo-kpi-sub">pgvector</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.embeddings">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neo charts -->
|
||||
<div class="hm-neo-charts">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head">
|
||||
<h3><span class="hm-live-dot"></span> Berichten per richting</h3>
|
||||
<small>klik voor detail</small>
|
||||
</div>
|
||||
<div class="hm-chart-wrap"><canvas id="hm-chart-direction"></canvas></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head">
|
||||
<h3><span class="hm-live-dot"></span> Agent activiteit</h3>
|
||||
<small>laatste events</small>
|
||||
</div>
|
||||
<div class="hm-chart-wrap"><canvas id="hm-chart-agents"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hermes-tabs">
|
||||
<button type="button" class="hermes-tab" :class="{ active: tab === 'feed' }" @click="setTab('feed')">📨 Live Feed</button>
|
||||
<button type="button" class="hermes-tab" :class="{ active: tab === 'pa' }" @click="setTab('pa')">🌐 PA Live (4×)</button>
|
||||
<button type="button" class="hermes-tab" :class="{ active: tab === 'graph' }" @click="setTab('graph')">🕸 Graph</button>
|
||||
<button type="button" class="hermes-tab" :class="{ active: tab === 'search' }" @click="setTab('search')">🔍 Memory</button>
|
||||
<button type="button" class="hermes-tab" :class="{ active: tab === 'control' }" @click="setTab('control')">⚙️ Control</button>
|
||||
</div>
|
||||
|
||||
<!-- LIVE FEED -->
|
||||
<div x-show="tab === 'feed'" class="hermes-grid">
|
||||
<aside class="panel hermes-sidebar">
|
||||
<div class="hm-team">
|
||||
<h3>Team — Foodlinkk leadership</h3>
|
||||
<template x-for="m in teamRoster" :key="m.chat_id">
|
||||
<div class="hm-team-card" :class="{ online: m.online || m.pa_mode }">
|
||||
<span class="hm-status-dot" :class="m.online ? 'on' : 'off'"></span>
|
||||
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
|
||||
<div class="hm-person">
|
||||
<strong x-text="m.name"></strong>
|
||||
<small>
|
||||
<span x-text="m.chat_id"></span>
|
||||
<span x-show="m.pa_mode" class="tag tag-pa">PA</span>
|
||||
<span x-show="m.message_count" x-text="' · ' + m.message_count + ' msgs'"></span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h3 style="font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin:1rem 0 0.5rem">Filter chat</h3>
|
||||
<button class="btn btn-sm" @click="selectChat(null)" :class="{ 'btn-primary': !selectedChatId }">Alle chats</button>
|
||||
<template x-for="m in teamRoster" :key="'c'+m.chat_id">
|
||||
<button class="hermes-conv-btn" @click="selectChat(m.chat_id)" :class="{ active: selectedChatId == m.chat_id }">
|
||||
<span class="conv-role hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
|
||||
<span x-text="m.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="panel hermes-feed">
|
||||
<div class="panel-header">
|
||||
<h3>Telegram live feed</h3>
|
||||
<span class="text-muted" x-text="live ? '🟢 Live (5s)' : '⏸'"></span>
|
||||
<button class="btn btn-sm" @click="refreshFeed()">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="hermes-messages">
|
||||
<template x-for="msg in messages" :key="msg.id">
|
||||
<div class="hermes-msg" :class="msg.direction === 'in' ? 'msg-in' : 'msg-out'">
|
||||
<div class="hermes-msg-meta">
|
||||
<span x-text="msg.direction === 'in' ? '← IN' : '→ OUT'"></span>
|
||||
<span x-text="msgLabel(msg)"></span>
|
||||
<span x-text="formatTime(msg.created_at)"></span>
|
||||
<span class="badge" x-show="msg.has_embedding">vector</span>
|
||||
<span class="badge" x-show="msg.agent_name" x-text="msg.agent_name"></span>
|
||||
</div>
|
||||
<div class="hermes-msg-body" x-text="msg.content_text || '(' + (msg.content_type || 'empty') + ')'"></div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!messages.length" class="text-muted">Nog geen berichten — stuur iets via @klaploper_bot</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- PA LIVE -->
|
||||
<div x-show="tab === 'pa'" class="panel">
|
||||
<div class="hermes-pa-header">
|
||||
<div>
|
||||
<strong>Personal PA — 4 site browsers</strong>
|
||||
<p class="text-muted" style="margin:0.25rem 0 0" x-text="paLive.query ? 'Zoekt: ' + paLive.query : 'Wacht op @PA via Telegram'"></p>
|
||||
</div>
|
||||
<span class="status-badge" :class="paLive.status || 'idle'" x-text="paStatusLabel()"></span>
|
||||
<span class="text-muted" x-show="paLiveUser" x-text="paLiveUser"></span>
|
||||
<button class="btn btn-sm btn-primary" @click="refreshPaLive()">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="hermes-pa-grid">
|
||||
<template x-for="slot in paSlots" :key="slot.label">
|
||||
<div class="hermes-pa-slot" :class="'status-' + (slot.status || 'idle')">
|
||||
<div class="hermes-pa-slot-head">
|
||||
<strong x-text="slot.label"></strong>
|
||||
<span class="status-badge" :class="slot.status || 'idle'" x-text="slotStatusLabel(slot.status)"></span>
|
||||
</div>
|
||||
<div class="hermes-pa-slot-body">
|
||||
<img x-show="slot.has_screenshot" :src="slotScreenshotUrl(slot.label)" :alt="slot.label" />
|
||||
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'waiting'">⏳ Wacht…</div>
|
||||
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'loading'">🔄 Bezig…</div>
|
||||
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && (!slot.status || slot.status === 'idle')">— idle —</div>
|
||||
<div class="hermes-pa-placeholder" x-show="slot.status === 'failed'" x-text="slot.error || 'Mislukt'"></div>
|
||||
</div>
|
||||
<div class="hermes-pa-slot-foot" x-text="slot.title || slot.url || slot.label"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GRAPH -->
|
||||
<div x-show="tab === 'graph'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Second brain — message graph</h3>
|
||||
<button class="btn btn-sm btn-primary" @click="loadGraph()">↻ Herlaad</button>
|
||||
<select class="form-input hermes-select" x-model="graphChatFilter" @change="loadGraph()">
|
||||
<option value="">Alle chats</option>
|
||||
<template x-for="m in teamRoster" :key="'g'+m.chat_id">
|
||||
<option :value="m.chat_id" x-text="m.role + ' — ' + m.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div id="hermes-graph" class="hermes-graph-canvas"></div>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH -->
|
||||
<div x-show="tab === 'search'" class="panel">
|
||||
<h3>Vector + full-text memory search</h3>
|
||||
<div class="hermes-search-bar">
|
||||
<input class="form-input" type="search" placeholder="Zoek in Telegram-geheugen…" x-model="searchQuery" @keydown.enter="runSearch()" />
|
||||
<button class="btn btn-primary" @click="runSearch()">Zoeken</button>
|
||||
</div>
|
||||
<div class="hermes-search-results">
|
||||
<template x-for="r in searchResults" :key="r.id">
|
||||
<div class="hermes-search-hit">
|
||||
<div class="hermes-msg-meta">
|
||||
<span x-text="msgLabel(r)"></span>
|
||||
<span x-text="formatTime(r.created_at)"></span>
|
||||
<span class="badge" x-text="'score ' + (r.score || 0).toFixed(2)"></span>
|
||||
</div>
|
||||
<p x-text="r.content_text"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTROL -->
|
||||
<div x-show="tab === 'control'" class="hermes-control-grid">
|
||||
<div class="panel">
|
||||
<h3>Hermes status</h3>
|
||||
<ul class="hermes-info-list">
|
||||
<li>Bot: <strong>@klaploper_bot</strong></li>
|
||||
<li>CEO: <strong>Aïssa</strong> · CTO: <strong>Mo</strong></li>
|
||||
<li>Control API: <span x-text="hermesOnline ? '🟢 Online' : '🔴 Offline'"></span></li>
|
||||
<li>Inbound: <span x-text="stats.inbound || 0"></span> · Outbound: <span x-text="stats.outbound || 0"></span></li>
|
||||
</ul>
|
||||
<div class="hermes-actions">
|
||||
<button class="btn btn-primary" @click="runAction('evening-briefing')" :disabled="actionBusy">🌙 Avondbriefing → CEO</button>
|
||||
<button class="btn" @click="runAction('morning-briefing')" :disabled="actionBusy">☀️ Ochtendbriefing</button>
|
||||
<button class="btn" @click="runAction('resume-jobs')" :disabled="actionBusy">🔧 Herstel jobs</button>
|
||||
</div>
|
||||
<p x-show="actionMsg" class="text-muted" style="margin-top:0.5rem" x-text="actionMsg"></p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Team</h3>
|
||||
<template x-for="m in teamRoster" :key="'t'+m.chat_id">
|
||||
<div class="hm-team-card" :class="{ online: m.online }">
|
||||
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
|
||||
<div class="hm-person"><strong x-text="m.name"></strong><small x-text="m.chat_id"></small></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="panel hermes-events">
|
||||
<h3>Agent events</h3>
|
||||
<template x-for="ev in agentEvents" :key="ev.id">
|
||||
<div class="hermes-event">
|
||||
<strong x-text="ev.title"></strong>
|
||||
<span class="badge" x-text="ev.agent_name"></span>
|
||||
<small x-text="formatTime(ev.created_at)"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/hermes-ui.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,612 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="marketingHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Marketing Command <span class="live-badge">Live</span></h1>
|
||||
<p class="subtitle">RSS trends · halal markt · weer · actie · CRM koppeling</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
|
||||
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="live-feed-bar" x-show="dashboard.rss?.length">
|
||||
<div class="ticker-track">
|
||||
<template x-for="r in dashboard.rss" :key="r.id">
|
||||
<a class="ticker-link" :href="r.link" target="_blank" rel="noopener" x-text="'📰 '+r.feed_name+': '+r.title"></a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card kpi-pulse"><span>RSS items</span><strong x-text="dashboard.rss?.length||0"></strong></div>
|
||||
<div class="kpi-card"><span>Markt trends</span><strong x-text="dashboard.trends?.length||0"></strong></div>
|
||||
<div class="kpi-card kpi-green"><span>Top kansen</span><strong x-text="dashboard.top_opportunities?.length||0"></strong></div>
|
||||
<div class="kpi-card"><span>Mentions</span><strong>{{ analytics.mention_count }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="vtabs-layout" style="--vt-color:#ff6b9d" x-ref="vtabsWrap">
|
||||
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
|
||||
<button type="button" class="vtab-btn" :class="tab==='live'?'active':''" @click="setTab('live')" data-vtab="live"><span class="vtab-icon">📡</span> Live Feed</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='reclame'?'active':''" @click="setTab('reclame')" data-vtab="reclame"><span class="vtab-icon">📁</span> Reclame folders</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='publish'?'active':''" @click="setTab('publish')" data-vtab="publish"><span class="vtab-icon">🚀</span> Automatisering</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='actie'?'active':''" @click="setTab('actie')" data-vtab="actie"><span class="vtab-icon">⚡</span> Actie</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='intel'?'active':''" @click="setTab('intel')" data-vtab="intel"><span class="vtab-icon">🎯</span> Intel</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='regelgeving'?'active':''" @click="setTab('regelgeving')" data-vtab="regelgeving"><span class="vtab-icon">⚖️</span> Regelgeving</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='bookmarks'?'active':''" @click="setTab('bookmarks')" data-vtab="bookmarks"><span class="vtab-icon">★</span> Opgeslagen RSS</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='social'?'active':''" @click="setTab('social')" data-vtab="social"><span class="vtab-icon">💬</span> Social</button>
|
||||
<button type="button" class="vtab-btn" :class="tab==='studio'?'active':''" @click="setTab('studio')" data-vtab="studio"><span class="vtab-icon">🎨</span> Studio</button>
|
||||
</nav>
|
||||
<div class="vtabs-content">
|
||||
<section x-show="tab==='live'" class="panel">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div>
|
||||
<h2>Kant-en-klaar & supermarkt RSS <span class="live-badge">Gefilterd</span></h2>
|
||||
<p class="hint">Alleen kant-en-klaar maaltijden, retail en supermarkten — geen algemeen nieuws.</p>
|
||||
<button class="btn btn-sm btn-pulse" @click="refreshRss()" :disabled="busy">RSS ophalen</button>
|
||||
<template x-for="r in dashboard.rss" :key="r.id">
|
||||
<div class="feed-card">
|
||||
<div class="source" x-text="r.feed_name+' · '+r.category"></div>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<p x-text="(r.summary||'').substring(0,140)+'…'"></p>
|
||||
<small x-text="r.published_at||r.fetched_at"></small>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="r.link" target="_blank" rel="noopener">Artikel lezen →</a>
|
||||
<a class="btn-source" :href="r.feed_url" target="_blank" rel="noopener">Bron feed →</a>
|
||||
<button type="button" class="btn-source btn-bookmark" :class="isBookmarked(r.id)?'saved':''" @click="toggleBookmark(r)" x-text="isBookmarked(r.id)?'★ Opgeslagen':'☆ Bewaren'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!dashboard.rss?.length" class="muted">Geen items — klik RSS ophalen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Markt trends <span class="live-badge">Live</span></h2>
|
||||
<template x-for="t in dashboard.trends" :key="t.id">
|
||||
<div class="feed-card">
|
||||
<strong x-text="t.trend_name"></strong>
|
||||
<p x-text="t.description||t.trend_category"></p>
|
||||
<span class="ticker-item" x-text="'Kans: '+Math.round((t.opportunity_score||0)*100)+'%'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<h3 style="margin-top:1.5rem">Top halal kansen</h3>
|
||||
<template x-for="o in dashboard.top_opportunities" :key="o.name">
|
||||
<div class="feed-card clickable-row" @click="window.location='/retail'">
|
||||
<strong x-text="o.chain+' · '+o.name"></strong>
|
||||
<p x-text="o.city+' · score '+Math.round(o.halal_opportunity_score||0)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ACTIE -->
|
||||
<section x-show="tab==='actie'" class="panel">
|
||||
<h2>Snelle acties <span class="live-badge">Pulse</span></h2>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem">
|
||||
<a class="btn btn-pulse" href="/retail">Open Retail 360</a>
|
||||
<a class="btn btn-pulse-green" href="/clients">CRM Clients</a>
|
||||
<a class="btn btn-pulse-purple" href="/deals">CRM Deals</a>
|
||||
<button class="btn btn-pulse" @click="refreshAll()">Trends verversen</button>
|
||||
</div>
|
||||
<h3>Herman Recommendations</h3>
|
||||
<div id="reco-list"><p class="muted">Laden…</p></div>
|
||||
<h3 style="margin-top:1.5rem">Geplande posts</h3>
|
||||
<table class="data-table"><thead><tr><th>Account</th><th>Content</th><th>Tijd</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>{% for p in scheduled_posts %}<tr>
|
||||
<td>{{ p.platform }} @{{ p.username }}</td><td>{{ p.content[:60] }}</td><td>{{ p.scheduled_time }}</td><td>{{ p.status }}</td>
|
||||
<td><button class="btn btn-sm btn-approve" @click="postStatus({{ p.id }}, 'approved')">✓</button>
|
||||
<button class="btn btn-sm btn-primary" @click="postStatus({{ p.id }}, 'posted')">Post</button></td></tr>{% endfor %}</tbody></table>
|
||||
</section>
|
||||
|
||||
<!-- RECLAME FOLDERS -->
|
||||
<section x-show="tab==='reclame'" class="panel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<div>
|
||||
<h2>Supermarkt reclame folders <span class="live-badge">Acties</span></h2>
|
||||
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
|
||||
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
|
||||
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
|
||||
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
|
||||
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
|
||||
</div>
|
||||
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
|
||||
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
|
||||
<option value="">Alle ketens</option>
|
||||
<template x-for="c in reclameChains" :key="c">
|
||||
<option :value="c" x-text="c"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select class="form-input" x-model="reclameFilter.folder_type" @change="loadPromos()">
|
||||
<option value="">Alle types</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="actie">Actie</option>
|
||||
<option value="bonus">Bonus</option>
|
||||
</select>
|
||||
<select class="form-input" x-model="reclameFilter.valid_days" @change="loadPromos()">
|
||||
<option value="">Alle geldigheid</option>
|
||||
<option value="7">Nog 7 dagen geldig</option>
|
||||
<option value="14">Nog 14 dagen geldig</option>
|
||||
<option value="30">Nog 30 dagen geldig</option>
|
||||
</select>
|
||||
<input class="form-input" x-model="reclameFilter.q" @input.debounce.400ms="loadPromos()" placeholder="Zoek op titel, keten…" />
|
||||
</div>
|
||||
<div class="promo-grid">
|
||||
<template x-for="p in filteredPromos" :key="p.id || p.folder_path">
|
||||
<a class="feed-card promo-card promo-card-link" :href="promoUrl(p)" target="_blank" rel="noopener">
|
||||
<img x-show="p.image_url" class="promo-cover" :src="p.image_url" :alt="p.title" loading="lazy" />
|
||||
<span class="ticker-item" x-text="p.chain||'Retail'"></span>
|
||||
<strong x-text="p.title"></strong>
|
||||
<p x-text="p.description||p.folder_label||''"></p>
|
||||
<small x-text="(p.valid_from||'?')+' → '+(p.valid_to||'?')"></small>
|
||||
<div class="feed-actions">
|
||||
<span class="btn-source">📁 Folder openen op Reclamefolder.nl →</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
|
||||
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
|
||||
<form class="form-grid" @submit.prevent="addPromo">
|
||||
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
|
||||
<input class="form-input" x-model="promoForm.title" placeholder="Titel actie" required />
|
||||
<input class="form-input" x-model="promoForm.folder_path" placeholder="Pad /nas/marketing/reclame/…" />
|
||||
<input class="form-input" x-model="promoForm.description" placeholder="Beschrijving / context" />
|
||||
<button class="btn btn-primary btn-pulse" type="submit">Folder toevoegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- AUTOMATISERING / SOCIAL PUBLISH -->
|
||||
<section x-show="tab==='publish'" class="panel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
<div>
|
||||
<h2>Social automatisering <span class="live-badge">202 Async</span></h2>
|
||||
<p class="hint">Publiceer naar alle kanalen tegelijk. Zonder API-keys worden kanalen overgeslagen.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-pulse-purple" href="/settings?tab=social">⚙️ Social API's instellen</a>
|
||||
</div>
|
||||
|
||||
<div class="publish-channels" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem">
|
||||
<template x-for="ch in publishChannels" :key="ch.platform">
|
||||
<label class="ticker-item publish-channel" :class="ch.configured ? 'channel-ready' : 'channel-skip'">
|
||||
<input type="checkbox" :value="ch.platform" x-model="publishForm.channels" />
|
||||
<span x-text="ch.label + (ch.configured ? ' ✓' : ' (skip)')"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="emoji-bar" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-bottom:0.5rem">
|
||||
<template x-for="em in emojis" :key="em">
|
||||
<button type="button" class="btn btn-sm" @click="insertEmoji(em)" x-text="em"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<textarea class="form-input" rows="5" x-model="publishForm.text" placeholder="Post tekst — UTF-8 emoji ondersteund 🥘✨" style="width:100%;margin-bottom:0.75rem"></textarea>
|
||||
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
|
||||
<label class="btn btn-secondary btn-sm">
|
||||
📷 Foto uploaden
|
||||
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
|
||||
</label>
|
||||
<img x-show="publishForm.image_url" :src="publishForm.image_url" alt="Preview" style="max-height:80px;border-radius:8px" />
|
||||
<button type="button" class="btn btn-primary btn-pulse" @click="submitPublish()" :disabled="publishBusy || !publishForm.text.trim()">
|
||||
🚀 Publiceren (achtergrond)
|
||||
</button>
|
||||
<span x-show="publishJobId" class="muted">Job #<span x-text="publishJobId"></span> · <span x-text="publishJobStatus"></span></span>
|
||||
</div>
|
||||
|
||||
<h3>Publicatie historie</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Tijd</th><th>Kanalen</th><th>Status</th><th>Resultaat</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="j in publishHistory" :key="j.id">
|
||||
<tr>
|
||||
<td x-text="j.id"></td>
|
||||
<td x-text="(j.created_at||'').substring(0,19).replace('T',' ')"></td>
|
||||
<td x-text="(j.channels||[]).join(', ')"></td>
|
||||
<td x-text="j.status"></td>
|
||||
<td><small x-text="publishResultSummary(j)"></small></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p x-show="!publishHistory.length" class="muted">Nog geen publicaties.</p>
|
||||
</section>
|
||||
|
||||
<!-- OPGESLAGEN RSS -->
|
||||
<section x-show="tab==='bookmarks'" class="panel">
|
||||
<h2>Opgeslagen RSS artikelen <span class="live-badge">★</span></h2>
|
||||
<template x-for="b in bookmarks" :key="b.id">
|
||||
<div class="feed-card saved-rss">
|
||||
<div class="source" x-text="(b.feed_name||'RSS')+' · '+((b.created_at||'').substring(0,10))"></div>
|
||||
<a :href="b.link" target="_blank" rel="noopener"><strong x-text="b.title"></strong></a>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="b.link" target="_blank">Artikel →</a>
|
||||
<button type="button" class="btn-source btn-bookmark saved" @click="removeBookmark(b.rss_item_id)">Verwijder ★</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!bookmarks.length" class="muted">Nog geen opgeslagen artikelen — klik ☆ Bewaren in Live Feed.</p>
|
||||
</section>
|
||||
|
||||
<!-- REGELGEVING & CBS -->
|
||||
<section x-show="tab==='regelgeving'" class="panel">
|
||||
<div class="regs-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<div>
|
||||
<h2>Regelgeving · CBS · Food markt <span class="live-badge">Live RSS</span></h2>
|
||||
<p class="hint">NVWA, EU food law, CBS statistiek en retail M&A — apart van kant-en-klaar nieuws.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-pulse" @click="refreshRegulations()" :disabled="busy">↻ Feeds ophalen</button>
|
||||
</div>
|
||||
<div class="regs-columns" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1.25rem">
|
||||
<div>
|
||||
<h3 style="font-size:0.9rem;color:#a855f7;margin:0 0 0.75rem">⚖️ Regelgeving & NVWA</h3>
|
||||
<template x-for="r in regRows.regelgeving" :key="'reg'+r.id">
|
||||
<div class="feed-card">
|
||||
<span class="ticker-item" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)">REG</span>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
|
||||
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!regRows.regelgeving?.length" class="muted">Geen items — klik Feeds ophalen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:0.9rem;color:#38bdf8;margin:0 0 0.75rem">📊 CBS statistiek</h3>
|
||||
<template x-for="r in regRows.cbs" :key="'cbs'+r.id">
|
||||
<div class="feed-card">
|
||||
<span class="ticker-item" style="background:rgba(56,189,248,0.15);color:#38bdf8">CBS</span>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
|
||||
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!regRows.cbs?.length" class="muted">Geen CBS feeds — refresh RSS.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:0.9rem;color:#ff9f43;margin:0 0 0.75rem">🏪 Food markt & M&A</h3>
|
||||
<template x-for="r in regRows.markt" :key="'mkt'+r.id">
|
||||
<div class="feed-card">
|
||||
<span class="ticker-item" style="background:rgba(255,159,67,0.15);color:#ff9f43">MKT</span>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
<div class="feed-actions">
|
||||
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
|
||||
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!regRows.markt?.length" class="muted">Geen markt news.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- INTEL -->
|
||||
<section x-show="tab==='intel'" class="panel">
|
||||
<h2>Marketing strategie · halal kant-en-klaar</h2>
|
||||
<div class="feed-card">
|
||||
<strong>🎯 Focus: halal-gap filialen</strong>
|
||||
<p>Filialen zonder halal certificering in buurten met hoge demografische vraag — direct benaderen via CRM koppeling in Retail 360.</p>
|
||||
<a class="btn btn-sm btn-pulse" href="/retail">Bekijk kansen →</a>
|
||||
</div>
|
||||
<div class="feed-card">
|
||||
<strong>📊 Data bronnen actief</strong>
|
||||
<p>CBS demografie · PDOK geocoding · Open-Meteo weer · RSS feeds (Foodlog, RetailDetail, VMT, Nu.nl) · OSM groothandels</p>
|
||||
</div>
|
||||
<div class="feed-card">
|
||||
<strong>🌤️ Weer & seizoen</strong>
|
||||
<p>Gebruik weer voorspelling per filiaal in Retail 360 om promoties te timen (warme dagen = kant-en-klaar salades, kou = stoofgerechten).</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" @click="loadSocialAnalytics()">Social analytics laden</button>
|
||||
<pre x-text="socialAnalytics" style="margin-top:1rem;font-size:0.8rem"></pre>
|
||||
</section>
|
||||
|
||||
<!-- SOCIAL (bestaande functionaliteit) -->
|
||||
<section x-show="tab==='social'" class="panel">
|
||||
<h2>Mentions</h2>
|
||||
<form class="form-grid" @submit.prevent="addMention">
|
||||
<select x-model="mentionForm.platform" class="form-input"><option>Instagram</option><option>TikTok</option><option>Facebook</option><option>LinkedIn</option></select>
|
||||
<input class="form-input" placeholder="Mention text" x-model="mentionForm.text" required />
|
||||
<button class="btn btn-primary btn-pulse" type="submit">Add mention</button>
|
||||
</form>
|
||||
<table class="data-table"><thead><tr><th>Platform</th><th>Text</th><th>Sentiment</th><th></th></tr></thead>
|
||||
<tbody>{% for m in social_mentions %}
|
||||
<tr><td>{{ m.platform }}</td><td>{{ m.text[:80] }}</td><td>{{ m.sentiment_score }}</td>
|
||||
<td><button class="btn btn-reject btn-sm" @click="deleteMention({{ m.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<h3 style="margin-top:1.5rem">Accounts</h3>
|
||||
<form class="form-grid" @submit.prevent="addAccount">
|
||||
<input class="form-input" placeholder="Platform" x-model="accountForm.platform" />
|
||||
<input class="form-input" placeholder="Username" x-model="accountForm.username" />
|
||||
<button class="btn btn-primary" type="submit">Add</button>
|
||||
</form>
|
||||
<table class="data-table"><thead><tr><th>Platform</th><th>Username</th><th>Active</th></tr></thead>
|
||||
<tbody>{% for a in accounts %}<tr><td>{{ a.platform }}</td><td>{{ a.username }}</td><td>{{ a.is_active }}</td></tr>{% endfor %}</tbody></table>
|
||||
</section>
|
||||
|
||||
<!-- STUDIO -->
|
||||
<section x-show="tab==='studio'" class="panel">
|
||||
<h2>AI Studio</h2>
|
||||
<form @submit.prevent="generateAI">
|
||||
<input class="form-input" x-model="aiForm.topic" placeholder="Topic — bijv. halal kant-en-klaar Jumbo Amsterdam" />
|
||||
<input class="form-input" x-model="aiForm.platform" placeholder="Platform" />
|
||||
<button class="btn btn-primary btn-pulse-purple" type="submit">Generate content</button>
|
||||
</form>
|
||||
<pre class="ai-output" x-text="aiOutput"></pre>
|
||||
<h3 style="margin-top:1.5rem">Agent rules</h3>
|
||||
<form class="form-grid" @submit.prevent="addRule">
|
||||
<input class="form-input" x-model="ruleForm.name" placeholder="Rule name" />
|
||||
<select x-model="ruleForm.condition_type" class="form-input"><option value="sentiment_below">sentiment_below</option><option value="mention_spike">mention_spike</option></select>
|
||||
<button class="btn btn-primary" type="submit">Add rule</button>
|
||||
</form>
|
||||
<table class="data-table"><thead><tr><th>Name</th><th>Condition</th><th>Active</th></tr></thead>
|
||||
<tbody>{% for r in agent_rules %}<tr><td>{{ r.name }}</td><td>{{ r.condition_type }}</td><td>{{ r.is_active }}</td></tr>{% endfor %}</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function marketingHub() {
|
||||
return {
|
||||
tab: new URLSearchParams(location.search).get('tab') || 'live',
|
||||
tabIndicatorTop: 0, tabIndicatorH: 44,
|
||||
busy: false, dashboard: {}, socialAnalytics: '',
|
||||
regRows: { regelgeving: [], cbs: [], markt: [] },
|
||||
promos: [], bookmarks: [], bookmarkIds: {},
|
||||
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
|
||||
reclameStats: { chains: 0 },
|
||||
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [] },
|
||||
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
|
||||
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨🍳','💚','📁','🎯'],
|
||||
promoForm: { chain: '', title: '', folder_path: '', description: '' },
|
||||
_pollStop: null,
|
||||
mentionForm: { platform: 'Instagram', text: '' },
|
||||
accountForm: { platform: 'Instagram', username: '' },
|
||||
ruleForm: { name: '', condition_type: 'sentiment_below', threshold: 2 },
|
||||
aiForm: { topic: '', platform: 'Instagram', tone: 'vriendelijk' },
|
||||
aiOutput: '',
|
||||
get filteredPromos() {
|
||||
return this.promos;
|
||||
},
|
||||
async init() {
|
||||
await this.loadDashboard();
|
||||
await this.loadBookmarks();
|
||||
if (this.tab === 'regelgeving') await this.loadRegulations();
|
||||
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
|
||||
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
|
||||
this.$nextTick(() => this.updateTabIndicator());
|
||||
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
|
||||
const el=document.getElementById('reco-list');
|
||||
if(!el) return;
|
||||
el.innerHTML=(d.items||[]).map(i=>`<div class="feed-card"><strong>${i.title}</strong><p>${i.description||''}</p>
|
||||
<button class="btn btn-sm btn-approve" onclick="fetch('/api/admin/recommendations/${i.id}/approve',{method:'POST'}).then(()=>location.reload())">Approve</button>
|
||||
<button class="btn btn-sm btn-reject" onclick="fetch('/api/admin/recommendations/${i.id}/dismiss',{method:'POST'}).then(()=>location.reload())">Dismiss</button></div>`).join('')||'<p class="muted">Geen open recommendations</p>';
|
||||
});
|
||||
},
|
||||
setTab(name) {
|
||||
this.tab = name;
|
||||
const u = new URL(location.href);
|
||||
if (name === 'live') u.searchParams.delete('tab');
|
||||
else u.searchParams.set('tab', name);
|
||||
history.replaceState({}, '', u);
|
||||
if (name === 'reclame') { this.loadReclameChains(); this.loadPromos(); }
|
||||
if (name === 'bookmarks') this.loadBookmarks();
|
||||
if (name === 'regelgeving') this.loadRegulations();
|
||||
if (name === 'publish') { this.loadPublishChannels(); this.loadPublishHistory(); }
|
||||
this.$nextTick(() => this.updateTabIndicator());
|
||||
},
|
||||
updateTabIndicator() {
|
||||
const nav = this.$refs.vtabsNav;
|
||||
if (!nav) return;
|
||||
const btn = nav.querySelector('[data-vtab="'+this.tab+'"]');
|
||||
if (!btn) return;
|
||||
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
|
||||
this.tabIndicatorH = btn.offsetHeight;
|
||||
},
|
||||
async loadReclameChains() {
|
||||
try {
|
||||
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
|
||||
this.reclameChains = d.chains || [];
|
||||
this.reclameStats.chains = d.count || this.reclameChains.length;
|
||||
} catch (e) { this.reclameChains = []; }
|
||||
},
|
||||
async loadPromos() {
|
||||
try {
|
||||
const params = new URLSearchParams({ status: 'active', limit: '200' });
|
||||
if (this.reclameFilter.chain) params.set('chain', this.reclameFilter.chain);
|
||||
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
|
||||
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
|
||||
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
|
||||
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
|
||||
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
|
||||
this.promos = items;
|
||||
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
|
||||
} catch (e) { this.promos = []; }
|
||||
},
|
||||
promoUrl(p) {
|
||||
const path = p.folder_path || '';
|
||||
if (path.startsWith('http')) return path;
|
||||
let meta = p.metadata || {};
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta); } catch (e) { meta = {}; } }
|
||||
const editionId = meta.edition_id;
|
||||
if (editionId) return 'https://www.reclamefolder.nl/f/folders/' + editionId + '/';
|
||||
const slug = meta.retailer_permaname || '';
|
||||
if (slug) return 'https://www.reclamefolder.nl/winkels/' + slug + '/';
|
||||
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
|
||||
},
|
||||
async refreshReclamefolders(toast = true) {
|
||||
this.busy = true;
|
||||
try {
|
||||
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
|
||||
await this.loadPromos();
|
||||
await this.loadReclameChains();
|
||||
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
|
||||
} catch (e) {
|
||||
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
|
||||
} finally { this.busy = false; }
|
||||
},
|
||||
async addPromo() {
|
||||
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
|
||||
this.promoForm = { chain: '', title: '', folder_path: '', description: '' };
|
||||
await this.loadPromos();
|
||||
Cockpit.toast('Folder geregistreerd', 'success');
|
||||
},
|
||||
async loadBookmarks() {
|
||||
try {
|
||||
const d = await fetch('/api/retail/rss/bookmarks?limit=100').then(r => r.json());
|
||||
this.bookmarks = d.items || [];
|
||||
this.bookmarkIds = {};
|
||||
this.bookmarks.forEach(b => { this.bookmarkIds[b.rss_item_id] = true; });
|
||||
} catch (e) { this.bookmarks = []; }
|
||||
},
|
||||
isBookmarked(id) { return !!this.bookmarkIds[id]; },
|
||||
async toggleBookmark(r) {
|
||||
if (this.isBookmarked(r.id)) {
|
||||
await this.removeBookmark(r.id);
|
||||
} else {
|
||||
await fetch('/api/retail/rss/bookmarks', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rss_item_id: r.id, title: r.title, link: r.link, feed_name: r.feed_name }),
|
||||
});
|
||||
await this.loadBookmarks();
|
||||
}
|
||||
},
|
||||
async removeBookmark(id) {
|
||||
await fetch('/api/retail/rss/bookmarks/' + id, { method: 'DELETE' });
|
||||
await this.loadBookmarks();
|
||||
},
|
||||
async loadRegulations() {
|
||||
try {
|
||||
const d = await fetch('/api/retail/regulations?limit=25').then(r => r.json());
|
||||
this.regRows = { regelgeving: d.regelgeving || [], cbs: d.cbs || [], markt: d.markt || [] };
|
||||
} catch (e) { this.regRows = { regelgeving: [], cbs: [], markt: [] }; }
|
||||
},
|
||||
async refreshRegulations() {
|
||||
this.busy = true;
|
||||
try { await fetch('/api/retail/rss/refresh', { method: 'POST' }); await this.loadRegulations(); }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async loadDashboard() {
|
||||
try { this.dashboard = await fetch('/api/retail/live-dashboard').then(r=>r.json()); } catch(e) { this.dashboard={}; }
|
||||
},
|
||||
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
|
||||
async refreshAll(toast) {
|
||||
this.busy = true;
|
||||
try {
|
||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||
await this.loadDashboard();
|
||||
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
||||
} finally { this.busy = false; }
|
||||
},
|
||||
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
|
||||
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
|
||||
async addAccount() { await Cockpit.api('/accounts',{method:'POST',body:JSON.stringify(this.accountForm)}); location.reload(); },
|
||||
async postStatus(id, status) { await Cockpit.api('/scheduled-posts/'+id+'/status',{method:'PATCH',body:JSON.stringify({status})}); location.reload(); },
|
||||
async addRule() { await Cockpit.api('/agent-rules',{method:'POST',body:JSON.stringify({...this.ruleForm,threshold:parseFloat(this.ruleForm.threshold),action:'notify',is_active:true})}); location.reload(); },
|
||||
async generateAI() { const r=await Cockpit.api('/api/ai/generate-content',{method:'POST',body:JSON.stringify(this.aiForm)}); this.aiOutput=r.content; },
|
||||
async loadSocialAnalytics() { const r=await Cockpit.api('/analytics/social'); this.socialAnalytics=JSON.stringify(r,null,2); },
|
||||
|
||||
insertEmoji(em) {
|
||||
this.publishForm.text = (this.publishForm.text || '') + em;
|
||||
},
|
||||
async loadPublishChannels() {
|
||||
try {
|
||||
const r = await fetch('/api/marketing/channels').then(res => res.json());
|
||||
const labels = { twitter: 'X', linkedin: 'LinkedIn', instagram: 'Instagram', facebook: 'Facebook', tiktok: 'TikTok', pinterest: 'Pinterest' };
|
||||
this.publishChannels = (r.items || []).map(c => ({
|
||||
...c, label: labels[c.platform] || c.platform,
|
||||
}));
|
||||
if (!this.publishForm.channels.length) {
|
||||
this.publishForm.channels = (r.platforms || []).slice();
|
||||
}
|
||||
} catch (e) { this.publishChannels = []; }
|
||||
},
|
||||
async loadPublishHistory() {
|
||||
try {
|
||||
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
|
||||
this.publishHistory = r.items || [];
|
||||
} catch (e) { this.publishHistory = []; }
|
||||
},
|
||||
publishResultSummary(j) {
|
||||
const res = j.result || {};
|
||||
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } }
|
||||
const parts = [];
|
||||
Object.entries(res).forEach(([k, v]) => {
|
||||
if (v && typeof v === 'object') parts.push(k + ': ' + (v.status || v.ok || JSON.stringify(v)));
|
||||
else parts.push(k + ': ' + v);
|
||||
});
|
||||
return parts.join(' · ') || '—';
|
||||
},
|
||||
async uploadImage(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const r = await fetch('/api/marketing/upload', { method: 'POST', body: fd }).then(res => res.json());
|
||||
this.publishForm.image_url = r.url;
|
||||
if (r.media_id) this.publishForm.media_ids = [r.media_id];
|
||||
Cockpit.toast('Afbeelding geüpload', 'success');
|
||||
} catch (e) { Cockpit.toast('Upload mislukt', 'error'); }
|
||||
ev.target.value = '';
|
||||
},
|
||||
async submitPublish() {
|
||||
this.publishBusy = true;
|
||||
try {
|
||||
const body = {
|
||||
text: this.publishForm.text,
|
||||
image_url: this.publishForm.image_url || null,
|
||||
media_ids: this.publishForm.media_ids || [],
|
||||
channels: this.publishForm.channels,
|
||||
};
|
||||
const r = await fetch('/api/marketing/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(res => res.json());
|
||||
this.publishJobId = r.job_id;
|
||||
this.publishJobStatus = r.status || 'queued';
|
||||
Cockpit.toast('Publicatie gestart (job #' + r.job_id + ')', 'success');
|
||||
this.pollPublishJob(r.job_id);
|
||||
await this.loadPublishHistory();
|
||||
} catch (e) {
|
||||
Cockpit.toast('Publicatie mislukt', 'error');
|
||||
} finally { this.publishBusy = false; }
|
||||
},
|
||||
pollPublishJob(jobId) {
|
||||
let attempts = 0;
|
||||
const tick = async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const r = await fetch('/api/marketing/publish/' + jobId).then(res => res.json());
|
||||
const job = r.job || {};
|
||||
this.publishJobStatus = job.status || '';
|
||||
if (job.status === 'completed' || job.status === 'failed' || attempts > 30) {
|
||||
await this.loadPublishHistory();
|
||||
Cockpit.toast('Job #' + jobId + ': ' + job.status, job.status === 'completed' ? 'success' : 'info');
|
||||
return;
|
||||
}
|
||||
} catch (e) { return; }
|
||||
setTimeout(tick, 2000);
|
||||
};
|
||||
setTimeout(tick, 1500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="monitorPage()">
|
||||
<header class="page-header">
|
||||
<h1>Site Monitor</h1>
|
||||
<p class="subtitle page-tip">Websites volgen — alles in PostgreSQL. <a href="/browser">Browser research ↗</a></p>
|
||||
</header>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card clickable-kpi"><div class="kpi-label">Active sites</div><div class="kpi-value">{{ stats.active_sites }}</div></div>
|
||||
<div class="kpi-card amber clickable-kpi"><div class="kpi-label">Changes 24h</div><div class="kpi-value">{{ stats.changes_24h }}</div></div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Website toevoegen</h2>
|
||||
<form class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center" @submit.prevent="addSite">
|
||||
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="form.url" required />
|
||||
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="form.name" />
|
||||
<button class="btn btn-primary" type="submit">Toevoegen</button>
|
||||
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Gemonitorde websites</h2>
|
||||
<table class="data-table"><thead><tr><th>Naam</th><th>URL</th><th>Laatste titel</th><th>Laatst gecheckt</th><th>Acties</th></tr></thead>
|
||||
<tbody>{% for s in sites %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
|
||||
<td>{{ s.last_title or '—' }}</td>
|
||||
<td>{{ s.last_crawled or '—' }}</td>
|
||||
<td class="btn-group">
|
||||
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="toggle({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
|
||||
<button class="btn btn-sm btn-reject" @click="remove({{ s.id }}, false)">Verwijder</button>
|
||||
</td></tr>{% else %}
|
||||
<tr><td colspan="5" class="empty-state">Geen sites.</td></tr>
|
||||
{% endfor %}</tbody></table>
|
||||
<p class="page-tip" style="margin-top:0.75rem">Verwijder = permanent uit database. Pauze = tijdelijk uitgeschakeld.</p>
|
||||
</section>
|
||||
<div class="grid-2">
|
||||
<section class="panel"><h2>Wijzigingen</h2>
|
||||
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Crawl logs</h2>
|
||||
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function monitorPage() {
|
||||
return {
|
||||
form: { url: '', name: '' },
|
||||
async addSite() {
|
||||
try {
|
||||
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(this.form) });
|
||||
Cockpit.toast('Site toegevoegd', 'success'); location.reload();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async crawlAll() {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => location.reload(), 2000);
|
||||
},
|
||||
async crawlOne(id) {
|
||||
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
|
||||
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => location.reload(), 2000);
|
||||
},
|
||||
async toggle(id) { await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' }); location.reload(); },
|
||||
async remove(id) {
|
||||
if (!confirm('Website permanent verwijderen uit monitoring?')) return;
|
||||
await Cockpit.api('/monitor/sites/' + id + '?hard=true', { method: 'DELETE' });
|
||||
Cockpit.toast('Verwijderd', 'success'); location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
|
||||
<header class="ops-header">
|
||||
<div>
|
||||
<h1>IT Ops Infrastructure</h1>
|
||||
<p class="ops-subtitle">Realtime Proxmox + service health topology</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">↻ Topology</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ops-kpis">
|
||||
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel topology-card">
|
||||
<svg viewBox="0 0 1200 600" class="topology-canvas">
|
||||
<g class="topology-lines">
|
||||
<template x-for="edge in edges" :key="edge.id">
|
||||
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<g>
|
||||
<template x-for="edge in edges" :key="'pulse-'+edge.id">
|
||||
<line class="pulse-line" :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<template x-for="node in nodes" :key="node.id">
|
||||
<g class="ops-node" :class="node.css" :transform="'translate('+node.x+','+node.y+')'">
|
||||
<circle r="36"></circle>
|
||||
<text text-anchor="middle" y="-2" x-text="node.label"></text>
|
||||
<text class="sub" text-anchor="middle" y="16" x-text="node.status"></text>
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function opsTopology() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: { counts: {} },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastUpdate: null,
|
||||
timer: null,
|
||||
positions: {
|
||||
'proxmox-host': { x: 600, y: 90 },
|
||||
'vm105-hermes': { x: 340, y: 230 },
|
||||
'vm106-command': { x: 860, y: 230 },
|
||||
'svc-gitea': { x: 190, y: 390 },
|
||||
'svc-ollama': { x: 480, y: 390 },
|
||||
'svc-cockpit': { x: 700, y: 390 },
|
||||
'svc-tools-api': { x: 860, y: 390 },
|
||||
'svc-email-agent': { x: 1020, y: 390 }
|
||||
},
|
||||
init() {
|
||||
this.loadStatus();
|
||||
this.loadTopology();
|
||||
this.timer = setInterval(() => this.loadTopology(), 30000);
|
||||
},
|
||||
async loadStatus() {
|
||||
try {
|
||||
const data = await fetch('/api/ops/status').then(r => r.json());
|
||||
this.summary = data || { counts: {} };
|
||||
this.lastUpdate = data.generated_at || new Date().toISOString();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
async loadTopology() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await fetch('/api/ops/topology').then(r => r.json());
|
||||
this.toGraph(data);
|
||||
await this.loadStatus();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refreshInfra() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await fetch('/api/ops/refresh', { method: 'POST' });
|
||||
await this.loadTopology();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
toGraph(payload) {
|
||||
const outNodes = [];
|
||||
const outEdges = [];
|
||||
const flatten = (n, parentId = null) => {
|
||||
const pos = this.positions[n.id] || { x: 80 + outNodes.length * 120, y: 520 };
|
||||
const status = (n.status || 'unknown').toLowerCase();
|
||||
const kind = (n.type || 'service').toLowerCase();
|
||||
const css = `node-${kind} status-${status}`;
|
||||
outNodes.push({ id: n.id, label: n.label, status: n.status || 'unknown', x: pos.x, y: pos.y, css });
|
||||
if (parentId) {
|
||||
const p = this.positions[parentId];
|
||||
if (p) {
|
||||
outEdges.push({ id: `${parentId}-${n.id}`, x1: p.x, y1: p.y + 36, x2: pos.x, y2: pos.y - 36 });
|
||||
}
|
||||
}
|
||||
(n.children || []).forEach(child => flatten(child, n.id));
|
||||
};
|
||||
(payload.nodes || []).forEach(root => flatten(root, null));
|
||||
this.nodes = outNodes;
|
||||
this.edges = outEdges;
|
||||
this.lastUpdate = payload.generated_at || new Date().toISOString();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.pack-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
|
||||
@media (max-width: 1024px) { .pack-grid { grid-template-columns: 1fr; } }
|
||||
.pack-preview {
|
||||
min-height: 460px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top, rgba(0,229,255,.08), rgba(0,0,0,.35));
|
||||
border: 1px solid rgba(0,229,255,.2);
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.pack-preview svg { width: 100%; max-height: 72vh; }
|
||||
.pack-tools { display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.75rem; }
|
||||
.elements-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; }
|
||||
.project-list { max-height: 260px; overflow: auto; display:flex; flex-direction:column; gap:.5rem; }
|
||||
.project-item { border:1px solid rgba(148,163,184,.15); border-radius:10px; padding:.6rem; background:rgba(0,0,0,.25); }
|
||||
.project-item small { color: var(--text-secondary); display:block; margin-top:.25rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="packagingStudio()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>Packaging Studio</h1>
|
||||
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p>
|
||||
</header>
|
||||
|
||||
<div class="pack-grid">
|
||||
<section class="panel">
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;">
|
||||
<label>Type
|
||||
<select class="form-input" x-model="form.type">
|
||||
<option value="folding_box">Folding box</option>
|
||||
<option value="wrap">Wrap</option>
|
||||
<option value="round_label">Round label</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Barcode value
|
||||
<input class="form-input" x-model="form.barcode_value" placeholder="8710000000012" />
|
||||
</label>
|
||||
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" /></label>
|
||||
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" /></label>
|
||||
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" /></label>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2>
|
||||
<div class="elements-grid">
|
||||
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label>
|
||||
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label>
|
||||
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label>
|
||||
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</label>
|
||||
</div>
|
||||
|
||||
<div class="pack-tools">
|
||||
<button class="btn btn-primary" @click="generate()" :disabled="loading">
|
||||
<span x-show="!loading">Genereer verpakking</span>
|
||||
<span x-show="loading">Genereren…</span>
|
||||
</button>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2>
|
||||
<div class="project-list">
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<div class="project-item">
|
||||
<strong x-text="p.spec.type"></strong>
|
||||
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small>
|
||||
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small>
|
||||
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="page-tip" x-show="!projects.length">Nog geen packaging projecten.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:1rem;">
|
||||
<div class="pack-preview">
|
||||
<div x-show="svg" x-html="svg"></div>
|
||||
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function packagingStudio() {
|
||||
return {
|
||||
loading: false,
|
||||
svg: '',
|
||||
projectId: '',
|
||||
projects: [],
|
||||
form: {
|
||||
type: 'folding_box',
|
||||
width_mm: 120,
|
||||
height_mm: 80,
|
||||
depth_mm: 40,
|
||||
barcode_value: '8710000000012',
|
||||
brand: {},
|
||||
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true }
|
||||
},
|
||||
async init() { await this.refreshProjects(); },
|
||||
downloadUrl(format) {
|
||||
if (!this.projectId) return '#';
|
||||
return '/api/packaging/download/' + this.projectId + '?format=' + format;
|
||||
},
|
||||
async refreshProjects() {
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/projects?limit=20');
|
||||
this.projects = r.items || [];
|
||||
} catch (e) {
|
||||
this.projects = [];
|
||||
}
|
||||
},
|
||||
async loadProject(id) {
|
||||
this.projectId = id;
|
||||
try {
|
||||
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text());
|
||||
this.svg = data;
|
||||
} catch (e) {
|
||||
Cockpit.toast('Kon project niet laden', 'error');
|
||||
}
|
||||
},
|
||||
async generate() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
this.svg = r.svg || '';
|
||||
this.projectId = r.id || '';
|
||||
await this.refreshProjects();
|
||||
Cockpit.toast('Packaging gegenereerd', 'success');
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,867 @@
|
||||
@import url('tokens.css');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg-root);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 0 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
display: block;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav a:hover,
|
||||
.nav a.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: var(--sidebar-width);
|
||||
flex: 1;
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-panel);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.15rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--kpi-accent, var(--accent-cyan));
|
||||
}
|
||||
|
||||
.kpi-card.purple::before { background: var(--accent-purple); }
|
||||
.kpi-card.amber::before { background: var(--accent-amber); }
|
||||
.kpi-card.green::before { background: var(--accent-green); }
|
||||
|
||||
.kpi-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.main {
|
||||
margin-left: 0;
|
||||
}
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.herman-briefing {
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
background: linear-gradient(135deg, var(--accent-cyan-dim), transparent);
|
||||
}
|
||||
|
||||
.herman-briefing .briefing-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.herman-briefing .briefing-body {
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
background: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
}
|
||||
|
||||
.agent-feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-feed li {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.agent-feed li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feed-time {
|
||||
flex-shrink: 0;
|
||||
width: 4.5rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.feed-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feed-message {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge-agent-herman,
|
||||
.badge-agent-cyan {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.badge-agent-sales,
|
||||
.badge-agent-purple {
|
||||
background: var(--accent-purple-dim);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.badge-agent-marketing,
|
||||
.badge-agent-amber {
|
||||
background: var(--accent-amber-dim);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.badge-agent-ops,
|
||||
.badge-agent-green {
|
||||
background: var(--accent-green-dim);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-status-needs_approval {
|
||||
background: var(--accent-amber-dim);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.badge-status-approved,
|
||||
.badge-status-completed {
|
||||
background: var(--accent-green-dim);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-status-rejected {
|
||||
background: var(--accent-red-dim);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.approval-queue .approval-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.approval-queue .approval-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.approval-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.agent-card.cyan { border-top: 2px solid var(--accent-cyan); }
|
||||
.agent-card.purple { border-top: 2px solid var(--accent-purple); }
|
||||
.agent-card.amber { border-top: 2px solid var(--accent-amber); }
|
||||
.agent-card.green { border-top: 2px solid var(--accent-green); }
|
||||
|
||||
.agent-card h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.agent-card p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
/* Fase 2-4: kanban, charts, voice */
|
||||
.kanban-board { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 1rem; margin-bottom: 1.25rem; }
|
||||
.kanban-column { min-width: 220px; flex: 0 0 220px; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 0.75rem; }
|
||||
.kanban-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin: 0 0 0.75rem; }
|
||||
.kanban-card { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.65rem; margin-bottom: 0.5rem; font-size: 0.8125rem; }
|
||||
.chart-placeholder { min-height: 160px; }
|
||||
.chart-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.45rem; font-size: 0.75rem; }
|
||||
.chart-bar .bar { height: 8px; background: var(--accent-cyan); border-radius: 4px; min-width: 4px; }
|
||||
.chart-bar .bar-purple { background: var(--accent-purple); }
|
||||
.voice-widget { text-align: center; }
|
||||
.voice-mic { margin: 1rem auto; min-width: 160px; }
|
||||
.voice-wave { display: flex; justify-content: center; gap: 0.35rem; height: 32px; align-items: flex-end; }
|
||||
.voice-wave span { width: 4px; height: 12px; background: var(--accent-cyan-dim); border-radius: 2px; animation: voice-pulse 1.2s ease-in-out infinite; }
|
||||
.voice-wave span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.voice-wave span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes voice-pulse { 0%, 100% { height: 8px; opacity: 0.5; } 50% { height: 24px; opacity: 1; } }
|
||||
.herman-form textarea { width: 100%; background: var(--bg-root); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); padding: 0.75rem; font-family: inherit; margin-bottom: 0.75rem; }
|
||||
|
||||
/* Command Center interactive UI */
|
||||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal.open { display: flex; }
|
||||
.modal-card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 1.5rem; max-width: 480px; width: 100%; box-shadow: var(--shadow-lg); }
|
||||
.drawer { position: fixed; top: 0; right: 0; width: min(420px, 90vw); height: 100vh; background: var(--bg-surface); border-left: 1px solid var(--border-subtle); transform: translateX(100%); transition: transform .25s ease; z-index: 90; padding: 1.5rem; overflow: auto; }
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; }
|
||||
.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
|
||||
.btn-sm { padding: .25rem .55rem; font-size: 12px; }
|
||||
.clickable-row { cursor: pointer; transition: background .15s; }
|
||||
.clickable-row:hover { background: var(--bg-hover); }
|
||||
.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; }
|
||||
.clickable-kpi:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.25); text-decoration: none; }
|
||||
.tab-bar { display: flex; gap: .35rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.tab-bar button { background: var(--bg-surface); border: 1px solid var(--border-subtle); color: var(--text-secondary); padding: .45rem .9rem; border-radius: var(--radius-md); cursor: pointer; }
|
||||
.tab-bar button.active { border-color: var(--accent-cyan); color: var(--text-primary); background: var(--bg-hover); }
|
||||
.toast-container { position: fixed; bottom: 1rem; right: 1rem; z-index: 200; display: flex; flex-direction: column; gap: .5rem; }
|
||||
.toast { padding: .75rem 1rem; border-radius: var(--radius-md); background: var(--bg-surface); border: 1px solid var(--border-subtle); animation: toastIn .2s ease; }
|
||||
.toast-success { border-color: var(--accent-green); }
|
||||
.toast-error { border-color: var(--accent-red, #f44); }
|
||||
.toast-out { opacity: 0; transition: opacity .3s; }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
||||
.form-input { width: 100%; padding: .55rem .75rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); background: var(--bg-root); color: var(--text-primary); }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-subtle); text-align: left; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; }
|
||||
.kanban-col { background: var(--bg-surface); border-radius: var(--radius-lg); padding: .75rem; border: 1px solid var(--border-subtle); min-height: 200px; }
|
||||
.kanban-card { background: var(--bg-root); border-radius: var(--radius-md); padding: .65rem; margin-bottom: .5rem; border: 1px solid var(--border-subtle); }
|
||||
.chat-layout { display: flex; flex-direction: column; height: calc(100vh - 4rem); }
|
||||
.chat-messages { flex: 1; overflow: auto; padding: 1rem; background: var(--bg-surface); border-radius: var(--radius-lg); margin-bottom: 1rem; }
|
||||
.chat-msg { margin-bottom: .75rem; padding: .65rem; border-radius: var(--radius-md); }
|
||||
.chat-msg.user { background: rgba(0,200,255,.08); }
|
||||
.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); }
|
||||
.chat-input { display: flex; gap: .5rem; }
|
||||
.page-tip { color: var(--text-muted); font-size: 13px; }
|
||||
.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; }
|
||||
.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; }
|
||||
body.drawer-open { overflow: hidden; }
|
||||
/* Herman Daily Briefing — visual dashboard */
|
||||
.herman-briefing { border-left: 3px solid var(--accent-cyan); }
|
||||
.herman-briefing-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
|
||||
.herman-briefing-header h2 { margin: 0; }
|
||||
.briefing-meta { font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
.briefing-summary-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.briefing-card {
|
||||
background: var(--surface-elevated, rgba(15, 23, 42, 0.6));
|
||||
border: 1px solid var(--border-subtle, rgba(148, 163, 184, 0.15));
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
.briefing-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent-gold, #e8a838); }
|
||||
.briefing-card.summary { border-color: rgba(56, 189, 248, 0.25); }
|
||||
.briefing-card.actions { border-color: rgba(232, 168, 56, 0.25); }
|
||||
.briefing-card.actions ul { margin: 0; padding-left: 1.1rem; color: var(--text-secondary); line-height: 1.55; }
|
||||
.briefing-card.actions li { margin-bottom: 0.35rem; }
|
||||
#briefing-summary-text { color: var(--text-primary, #e2e8f0); line-height: 1.6; font-size: 0.95rem; }
|
||||
|
||||
.briefing-kpi-row { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-bottom: 1.25rem; }
|
||||
.briefing-stat {
|
||||
flex: 1; min-width: 100px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
.briefing-stat.green { border-color: rgba(62, 207, 142, 0.35); }
|
||||
.briefing-stat.purple { border-color: rgba(167, 139, 250, 0.35); }
|
||||
.briefing-stat.cyan { border-color: rgba(56, 189, 248, 0.35); }
|
||||
.briefing-stat.amber { border-color: rgba(232, 168, 56, 0.35); }
|
||||
.briefing-stat-label { display: block; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.briefing-stat-value { display: block; font-size: 1.35rem; font-weight: 600; color: var(--text-primary); margin-top: 0.15rem; }
|
||||
|
||||
.briefing-charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
@media (max-width: 900px) { .briefing-charts-grid { grid-template-columns: 1fr; } }
|
||||
.briefing-chart-panel {
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.briefing-details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||
@media (max-width: 900px) { .briefing-details-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.briefing-pending-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.briefing-toggle { margin-top: 0.5rem; }
|
||||
.briefing-full-report {
|
||||
display: none;
|
||||
margin-top: 0.75rem;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.briefing-full-report.open { display: block; }
|
||||
|
||||
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
|
||||
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
|
||||
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
|
||||
/* Settings page */
|
||||
.settings-header { margin-bottom: 0.5rem; }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.settings-tab {
|
||||
display: inline-block;
|
||||
padding: 0.65rem 1.1rem;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.settings-tab:hover { color: var(--text-primary, #e2e8f0); }
|
||||
.settings-tab.active {
|
||||
color: var(--accent-gold, #e8a838);
|
||||
border-bottom-color: var(--accent-gold, #e8a838);
|
||||
}
|
||||
|
||||
.settings-panel { border-left: 3px solid var(--accent-gold, #e8a838); }
|
||||
.settings-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.settings-active-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.email-account-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.email-account-card {
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.email-account-card.is-active { border-color: rgba(62, 207, 142, 0.35); }
|
||||
.email-account-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.email-account-card-head strong { display: block; }
|
||||
.email-account-card-head .muted { font-size: 0.85rem; color: var(--text-muted); }
|
||||
.email-account-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-modal { max-width: 520px; width: 100%; }
|
||||
.settings-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; }
|
||||
.form-label .form-input { margin-top: 0.25rem; }
|
||||
.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; }
|
||||
|
||||
.settings-details {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.settings-details summary { cursor: pointer; color: var(--text-secondary); font-size: 0.85rem; }
|
||||
|
||||
.sidebar .nav-settings {
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
/* ── Horizontal top navigation ── */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: linear-gradient(180deg, #0f172a 0%, #0b1220 100%);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding: 0.65rem 1.25rem 0;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.topbar-brand {
|
||||
display: inline-block;
|
||||
margin-right: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.topbar-brand .brand-title { margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; color: var(--accent-gold, #e8a838); text-transform: uppercase; }
|
||||
.topbar-brand .brand-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary, #e2e8f0); }
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem 1.25rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.topnav-herman {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: -1px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
color: #0f172a;
|
||||
background: linear-gradient(135deg, #e8a838 0%, #f0c060 100%);
|
||||
border: 1px solid rgba(232, 168, 56, 0.5);
|
||||
border-bottom: none;
|
||||
box-shadow: 0 -2px 12px rgba(232, 168, 56, 0.25);
|
||||
}
|
||||
.topnav-herman:hover { filter: brightness(1.05); color: #0f172a; }
|
||||
.topnav-herman.active { background: linear-gradient(135deg, #e8a838 0%, #f5d078 100%); }
|
||||
|
||||
.topnav-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem;
|
||||
padding-bottom: 0.45rem;
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.12);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.topnav-group:first-of-type { border-left: none; padding-left: 0; }
|
||||
.topnav-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted, #64748b);
|
||||
margin-right: 0.35rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
.topnav-group a {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
border: 1px solid transparent;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topnav-group a:hover {
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.topnav-group a.active {
|
||||
color: var(--accent-cyan, #38bdf8);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border-color: rgba(56, 189, 248, 0.25);
|
||||
}
|
||||
.topnav-group-system { margin-left: auto; border-left: 1px solid rgba(148, 163, 184, 0.12); }
|
||||
|
||||
.main-topnav {
|
||||
padding: 1.25rem 1.5rem 2rem;
|
||||
min-height: calc(100vh - 80px);
|
||||
background: var(--bg-root, #070b14);
|
||||
}
|
||||
|
||||
/* Herman hub layout */
|
||||
.herman-hub-header { margin-bottom: 1rem; }
|
||||
.herman-hub-header h1 { margin: 0 0 0.25rem; }
|
||||
.herman-hub-header .subtitle { margin: 0; }
|
||||
|
||||
.hub-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .hub-kpi-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 700px) { .hub-kpi-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 1000px) { .hub-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.hub-section-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.hub-section-tabs button, .hub-section-tabs a {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hub-section-tabs .active {
|
||||
color: var(--accent-gold);
|
||||
border-color: rgba(232, 168, 56, 0.3);
|
||||
background: rgba(232, 168, 56, 0.08);
|
||||
}
|
||||
|
||||
.hub-feed-item {
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.hub-feed-time { color: var(--text-muted); font-size: 0.75rem; margin-right: 0.5rem; }
|
||||
|
||||
/* Hide old sidebar layout when topnav is active */
|
||||
body:has(.topbar) .layout { display: block; }
|
||||
body:has(.topbar) .sidebar { display: none; }
|
||||
body:has(.topbar) .main { margin-left: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topnav { gap: 0.25rem 0.75rem; }
|
||||
.topnav-group { padding-left: 0.5rem; }
|
||||
.topnav-label { display: none; }
|
||||
}
|
||||
|
||||
/* Compact Herman briefing charts */
|
||||
.briefing-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
max-height: 180px;
|
||||
}
|
||||
@media (max-width: 1100px) { .briefing-charts-grid { grid-template-columns: repeat(2, 1fr); max-height: none; } }
|
||||
.briefing-chart-panel {
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-height: 0 !important;
|
||||
height: 155px;
|
||||
position: relative;
|
||||
}
|
||||
.briefing-chart-panel canvas { max-height: 130px !important; }
|
||||
.briefing-summary-grid { margin-bottom: 0.75rem; }
|
||||
.briefing-kpi-row { margin-bottom: 0.75rem; }
|
||||
.briefing-kpi-row .briefing-stat { padding: 0.45rem 0.6rem; }
|
||||
.briefing-kpi-row .briefing-stat-value { font-size: 1.1rem; }
|
||||
|
||||
.browser-instruct-box {
|
||||
background: rgba(56, 189, 248, 0.06);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.browser-instruct-box textarea { min-height: 70px; }
|
||||
.browser-steps-log {
|
||||
font-size: 0.8rem; color: var(--text-muted);
|
||||
margin-top: 0.5rem; max-height: 100px; overflow: auto;
|
||||
}
|
||||
.browser-action-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
|
||||
.novnc-fallback {
|
||||
padding: 1rem; text-align: center; background: #111;
|
||||
border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
|
||||
}
|
||||
.novnc-fallback a { color: var(--accent-cyan); }
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="productsCrud()">
|
||||
<header class="page-header"><h1>Products</h1><p class="subtitle page-tip">SKU catalog with margin calculator.</p></header>
|
||||
<button class="btn btn-primary" @click="show=true; id=null">Add product</button>
|
||||
<section class="panel"><h2>Margin calculator</h2>
|
||||
<div class="form-grid"><input type="number" class="form-input" x-model="margin.cost" placeholder="Cost" />
|
||||
<input type="number" class="form-input" x-model="margin.sell" placeholder="Sell" />
|
||||
<button class="btn btn-secondary" @click="calcMargin()">Calculate</button></div>
|
||||
<p x-show="margin.result">Margin: <strong x-text="margin.result"></strong>%</p></section>
|
||||
<table class="data-table"><thead><tr><th>Name</th><th>Status</th><th>Margin %</th><th></th></tr></thead>
|
||||
<tbody>{% for p in products %}<tr class="clickable-row" @click="edit({{ p.id }}, '{{ p.name|e }}', '{{ p.status|e }}', {{ p.margin_pct or 0 }})">
|
||||
<td>{{ p.name }}</td><td>{{ p.status }}</td><td>{{ p.margin_pct }}</td>
|
||||
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ p.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
|
||||
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.status" /><input type="number" class="form-input" x-model="form.margin_pct" />
|
||||
<button class="btn btn-primary" @click="save()">Save</button></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function productsCrud() {
|
||||
return {
|
||||
show: false, id: null, form: { name: '', status: 'concept', margin_pct: 0, client_id: null },
|
||||
margin: { cost: 0, sell: 0, result: null },
|
||||
edit(id, name, status, margin_pct) { this.id=id; this.form={name,status,margin_pct,client_id:null}; this.show=true; },
|
||||
async calcMargin() {
|
||||
const r = await Cockpit.api('/products/margin-calc', { method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }) });
|
||||
this.margin.result = r.margin_pct;
|
||||
},
|
||||
async save() {
|
||||
const body = { ...this.form, margin_pct: parseFloat(this.form.margin_pct) };
|
||||
if (this.id) await Cockpit.api('/products/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
},
|
||||
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/products/' + id, { method: 'DELETE' }); location.reload(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,258 @@
|
||||
/* Telegram-inspired pulse & live feed theme */
|
||||
:root {
|
||||
--pulse-blue: #2aabee;
|
||||
--pulse-green: #22c55e;
|
||||
--pulse-purple: #a855f7;
|
||||
--pulse-orange: #f97316;
|
||||
--pulse-glow: rgba(42, 171, 238, 0.45);
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 var(--pulse-glow); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(42, 171, 238, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(42, 171, 238, 0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
@keyframes live-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.btn-pulse {
|
||||
position: relative;
|
||||
animation: pulse-ring 2s infinite;
|
||||
border: 1px solid var(--pulse-blue) !important;
|
||||
background: linear-gradient(135deg, rgba(42,171,238,0.15), rgba(42,171,238,0.05)) !important;
|
||||
}
|
||||
|
||||
.btn-pulse-green {
|
||||
animation-name: pulse-ring;
|
||||
--pulse-glow: rgba(34, 197, 94, 0.45);
|
||||
border-color: var(--pulse-green) !important;
|
||||
background: linear-gradient(135deg, rgba(34,197,94,0.15), rgba(34,197,94,0.05)) !important;
|
||||
}
|
||||
|
||||
.btn-pulse-purple {
|
||||
--pulse-glow: rgba(168, 85, 247, 0.45);
|
||||
border-color: var(--pulse-purple) !important;
|
||||
background: linear-gradient(135deg, rgba(168,85,247,0.15), rgba(168,85,247,0.05)) !important;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--pulse-green);
|
||||
}
|
||||
|
||||
.live-badge::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--pulse-green);
|
||||
animation: pulse-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.live-feed-bar {
|
||||
background: linear-gradient(90deg, #0f172a 25%, #1e3a5f 50%, #0f172a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: live-shimmer 3s linear infinite;
|
||||
border: 1px solid rgba(42, 171, 238, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticker-track {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 40s linear infinite;
|
||||
}
|
||||
|
||||
.ticker-track:hover { animation-play-state: paused; }
|
||||
|
||||
.side-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.side-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.side-nav-btn:hover {
|
||||
background: rgba(42, 171, 238, 0.08);
|
||||
border-color: rgba(42, 171, 238, 0.2);
|
||||
}
|
||||
|
||||
.side-nav-btn.active {
|
||||
background: rgba(42, 171, 238, 0.15);
|
||||
border-color: var(--pulse-blue);
|
||||
box-shadow: 0 0 12px rgba(42, 171, 238, 0.2);
|
||||
}
|
||||
|
||||
.section-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.section-tab {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.section-tab.active {
|
||||
border-color: var(--pulse-blue);
|
||||
background: rgba(42, 171, 238, 0.2);
|
||||
box-shadow: 0 0 8px rgba(42, 171, 238, 0.25);
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #334155;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.feed-card:hover {
|
||||
border-color: var(--pulse-blue);
|
||||
}
|
||||
|
||||
.feed-card .source {
|
||||
font-size: 0.7rem;
|
||||
color: var(--pulse-blue);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.weather-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weather-day {
|
||||
flex: 1;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(42, 171, 238, 0.08);
|
||||
border: 1px solid rgba(42, 171, 238, 0.15);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.weather-day strong { display: block; font-size: 1rem; }
|
||||
|
||||
.note-bubble {
|
||||
background: rgba(42, 171, 238, 0.08);
|
||||
border-left: 3px solid var(--pulse-blue);
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.milestone-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.milestone-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--pulse-orange);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
|
||||
.milestone-dot.done { background: var(--pulse-green); animation: none; }
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.pro-tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #334155;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pro-tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pro-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--pulse-blue);
|
||||
color: var(--pulse-blue);
|
||||
}
|
||||
|
||||
.pro-tab:hover { opacity: 1; }
|
||||
|
||||
.kpi-pulse strong {
|
||||
background: linear-gradient(90deg, var(--pulse-blue), var(--pulse-purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
|
||||
<style>
|
||||
.reports-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
.report-card {
|
||||
padding: 1rem; border-radius: 12px; background: rgba(15,23,42,0.8);
|
||||
border: 1px solid rgba(148,163,184,0.12); transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
.report-card:hover { border-color: rgba(99,102,241,0.4); transform: translateY(-2px); }
|
||||
.report-card h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
||||
.report-card .count { font-size: 1.5rem; font-weight: 700; color: #e2e8f0; }
|
||||
.report-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="reportsHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Reports & Export Hub</h1>
|
||||
<p class="subtitle">Exporteer alle data uit het systeem · CSV · JSON · dagrapporten</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-pulse-green" @click="exportAll()" :disabled="busy">⬇ Volledige export (JSON)</button>
|
||||
<button class="btn btn-pulse" @click="generate()" :disabled="busy">✨ Genereer dagrapport</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card kpi-pulse"><span>Datasets</span><strong x-text="datasets.length"></strong></div>
|
||||
<div class="kpi-card"><span>Totaal records</span><strong x-text="totalRecords"></strong></div>
|
||||
<div class="kpi-card kpi-green"><span>Briefings</span><strong>{{ briefings|length }}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Data export <span class="live-badge">Alle tabellen</span></h2>
|
||||
<p class="hint">Klik CSV of JSON per dataset. Volledige bundle bevat alles in één JSON bestand.</p>
|
||||
<div class="reports-grid">
|
||||
<template x-for="d in datasets" :key="d.id">
|
||||
<div class="report-card">
|
||||
<h3 x-text="d.label"></h3>
|
||||
<div class="count" x-text="d.count.toLocaleString('nl-NL')"></div>
|
||||
<small class="muted" x-text="d.id"></small>
|
||||
<div class="report-actions">
|
||||
<a class="btn btn-sm btn-pulse" :href="'/reports/api/export/'+d.id+'?format=csv'" target="_blank">CSV ↓</a>
|
||||
<a class="btn btn-sm" :href="'/reports/api/export/'+d.id+'?format=json'" target="_blank">JSON ↓</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top:1rem">
|
||||
<h2>Dagrapport preview</h2>
|
||||
<pre class="briefing-body" x-text="latest" style="white-space:pre-wrap;max-height:400px;overflow:auto;background:rgba(0,0,0,0.3);padding:1rem;border-radius:8px"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top:1rem">
|
||||
<h2>Briefing archief</h2>
|
||||
<ul>{% for b in briefings %}
|
||||
<li class="clickable-row" @click="showBrief({{ b.content|tojson }})">{{ b.created_at }} — {{ b.content[:80] }}…</li>
|
||||
{% else %}<li class="muted">Nog geen rapporten</li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function reportsHub() {
|
||||
return {
|
||||
datasets: {{ datasets | tojson }},
|
||||
latest: '', busy: false,
|
||||
get totalRecords() { return this.datasets.reduce((a,d) => a + (d.count||0), 0); },
|
||||
init() {},
|
||||
showBrief(t) { this.latest = t; },
|
||||
exportAll() {
|
||||
window.open('/reports/api/export-all?format=json&limit=5000', '_blank');
|
||||
Cockpit.toast('Volledige export gestart', 'success');
|
||||
},
|
||||
async generate() {
|
||||
this.busy = true;
|
||||
Cockpit.toast('Dagrapport wordt gegenereerd…', 'info');
|
||||
try {
|
||||
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
|
||||
this.latest = r.content || '';
|
||||
Cockpit.toast('Dagrapport klaar', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,750 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="/static/retail.css?v=2" />
|
||||
|
||||
<div x-data="retailIntel()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Retail Intelligence <span class="live-badge">Live</span></h1>
|
||||
<p class="subtitle">360° filiaal view · CBS · halal · CRM · groothandels · trends</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="refreshRss()" :disabled="busy">↻ RSS feeds</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="importWholesale()" :disabled="busy">Groothandels import</button>
|
||||
<button class="btn btn-sm btn-pulse-purple" @click="syncCities()" :disabled="busy">Stad demografie</button>
|
||||
<a class="btn btn-sm" :href="'/api/retail/export?' + params()" target="_blank">Export CSV</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="live-feed-bar" x-show="rssItems.length">
|
||||
<div class="ticker-track">
|
||||
<template x-for="r in rssItems.slice(0,10)" :key="'r'+r.id">
|
||||
<a class="ticker-link" :href="r.link" target="_blank" rel="noopener" x-text="'📰 '+r.feed_name+': '+r.title"></a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card kpi-pulse"><span>Totaal filialen</span><strong x-text="stats.total||0"></strong></div>
|
||||
<div class="kpi-card kpi-green"><span>CRM actief</span><strong x-text="stats.active_partnerships||0"></strong></div>
|
||||
<div class="kpi-card"><span>Halal cert.</span><strong x-text="stats.halal_certified_count||0"></strong></div>
|
||||
<div class="kpi-card"><span>CBS data</span><strong x-text="stats.with_area_data||0"></strong></div>
|
||||
<div class="kpi-card"><span>Groothandels</span><strong x-text="wholesaleCount"></strong></div>
|
||||
<div class="kpi-card"><span>Resultaat</span><strong x-text="resultCount"></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="retail-workspace" :class="['stores','wholesale','opportunities','cities'].includes(mode) ? 'with-filters' : ''">
|
||||
<aside class="retail-sidebar panel">
|
||||
<nav class="side-nav">
|
||||
<button class="side-nav-btn" :class="mode==='stores'?'active':''" @click="mode='stores'; apply()">🏪 Supermarkten</button>
|
||||
<button class="side-nav-btn" :class="mode==='wholesale'?'active':''" @click="mode='wholesale'; loadWholesale()">📦 Groothandels</button>
|
||||
<button class="side-nav-btn" :class="mode==='opportunities'?'active':''" @click="mode='opportunities'; loadOpportunities()">🎯 Kansen</button>
|
||||
<button class="side-nav-btn" :class="mode==='cities'?'active':''" @click="mode='cities'; loadCities()">🏙️ Steden · CBS</button>
|
||||
<a class="side-nav-btn" href="/marketing?tab=regelgeving">⚖️ Regelgeving</a>
|
||||
<a class="side-nav-btn" href="/clients">👥 CRM Clients</a>
|
||||
<a class="side-nav-btn" href="/deals">💼 CRM Deals</a>
|
||||
<a class="side-nav-btn" href="/marketing">📣 Marketing Hub</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<aside class="retail-filters panel" x-show="mode==='wholesale'" x-cloak>
|
||||
<div class="filters-head"><h3>Groothandel filters</h3></div>
|
||||
<details class="filter-group" open>
|
||||
<summary>🔎 Zoeken</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field"><span class="filter-label">Zoek</span>
|
||||
<input type="text" x-model="wf.q" @keyup.enter="loadWholesale()" placeholder="Naam, adres, e-mail">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Provincie</span>
|
||||
<select x-model="wf.province" @change="loadWholesale()"><option value="">Alle</option>
|
||||
<template x-for="p in wholesaleMeta.provinces||[]" :key="p.province">
|
||||
<option :value="p.province" x-text="p.province+' ('+p.n+')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Stad</span>
|
||||
<input type="text" x-model="wf.city" @change="loadWholesale()" placeholder="Amsterdam">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Sorteer</span>
|
||||
<select x-model="wf.sort" @change="loadWholesale()">
|
||||
<option value="name">Naam A-Z</option><option value="city">Stad</option><option value="province">Provincie</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<details class="filter-group">
|
||||
<summary>📞 Contact</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.has_phone" @change="loadWholesale()"><span>Heeft telefoon</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.has_email" @change="loadWholesale()"><span>Heeft e-mail</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.halal_certified" @change="loadWholesale()"><span>Halal gecertificeerd</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-sm btn-block btn-pulse" @click="loadWholesale()" :disabled="loading">Toepassen</button>
|
||||
<button class="btn btn-sm btn-block" @click="resetWholesaleFilters()">Reset</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="retail-filters panel" x-show="mode==='opportunities'" x-cloak>
|
||||
<div class="filters-head"><h3>Kansen filters</h3></div>
|
||||
<label class="filter-field"><span class="filter-label">Keten</span>
|
||||
<select x-model="of.chain" @change="loadOpportunities()"><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 class="filter-field"><span class="filter-label">Provincie</span>
|
||||
<select x-model="of.province" @change="loadOpportunities()"><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 class="filter-field"><span class="filter-label">Min. score</span>
|
||||
<input type="number" min="0" max="100" x-model.number="of.min_score" @change="loadOpportunities()">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Max. resultaten</span>
|
||||
<input type="number" min="10" max="500" x-model.number="of.limit" @change="loadOpportunities()">
|
||||
</label>
|
||||
<button class="btn btn-sm btn-block btn-pulse" @click="loadOpportunities()">Toepassen</button>
|
||||
</aside>
|
||||
|
||||
<aside class="retail-filters panel" x-show="mode==='cities'" x-cloak>
|
||||
<div class="filters-head"><h3>Steden filters</h3></div>
|
||||
<label class="filter-field"><span class="filter-label">Zoek stad</span>
|
||||
<input type="text" x-model="cf.q" @keyup.enter="loadCities()" placeholder="Amsterdam">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Min. inwoners</span>
|
||||
<input type="number" x-model.number="cf.min_population" @change="loadCities()">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Min. halal-markt %</span>
|
||||
<input type="number" step="0.1" x-model.number="cf.min_muslim_pct" @change="loadCities()">
|
||||
</label>
|
||||
<label class="filter-field"><span class="filter-label">Sorteer</span>
|
||||
<select x-model="cf.sort" @change="loadCities()">
|
||||
<option value="population">Inwoners ↓</option>
|
||||
<option value="muslim">Halal-markt ↓</option>
|
||||
<option value="stores">Filialen ↓</option>
|
||||
<option value="city">Naam A-Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-block btn-pulse" @click="loadCities()">Toepassen</button>
|
||||
</aside>
|
||||
|
||||
<aside class="retail-filters panel" x-show="mode==='stores'" x-cloak>
|
||||
<div class="filters-head">
|
||||
<h3>Filters</h3>
|
||||
<span class="filter-count" x-show="activeFilterCount" x-text="activeFilterCount"></span>
|
||||
</div>
|
||||
|
||||
<details class="filter-group" open>
|
||||
<summary>📍 Locatie</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Keten</span>
|
||||
<select x-model="f.chain" @change="apply()"><option value="">Alle ketens</option>
|
||||
<template x-for="c in meta.chains" :key="c.chain"><option :value="c.chain" x-text="c.chain+' ('+c.n+')'"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Provincie</span>
|
||||
<select x-model="f.province" @change="apply()"><option value="">Alle provincies</option>
|
||||
<template x-for="p in meta.provinces" :key="p.province"><option :value="p.province" x-text="p.province+' ('+p.n+')'"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Stad</span>
|
||||
<input type="text" x-model="f.city" @change="apply()" placeholder="Amsterdam">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Postcode</span>
|
||||
<input type="text" x-model="f.postcode_prefix" @change="apply()" placeholder="1012">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group" open>
|
||||
<summary>🔎 Zoeken</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Zoekveld</span>
|
||||
<input type="text" x-model="f.q" @keyup.enter="apply()" placeholder="Naam, adres, manager">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Sorteer</span>
|
||||
<select x-model="f.sort" @change="apply()">
|
||||
<option value="name">Naam A-Z</option>
|
||||
<option value="halal_opportunity">Halal kans ↓</option>
|
||||
<option value="population">Bevolking ↓</option>
|
||||
<option value="chain">Keten</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>🤝 CRM & partnership</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Partnership status</span>
|
||||
<select x-model="f.partnership" @change="apply()"><option value="">Alle statussen</option>
|
||||
<template x-for="p in (meta.partnerships||[])" :key="p.partnership_status">
|
||||
<option :value="p.partnership_status" x-text="(p.partnership_status||'none')+' ('+p.n+')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field filter-check">
|
||||
<input type="checkbox" x-model="f.linked_to_crm" @change="apply()">
|
||||
<span>Gekoppeld aan CRM</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>☪️ Halal</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.halal_gap_only" @change="apply()"><span>Halal-gap kansen</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.halal_certified" @change="apply()"><span>Halal gecertificeerd</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_halal_section" @change="apply()"><span>Halal schap</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_halal_cert_registry" @change="apply()"><span>In halal registry</span></label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Kans score min</span>
|
||||
<input type="number" min="0" max="100" x-model.number="f.min_halal_opportunity" @change="apply()" placeholder="35">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Kans score max</span>
|
||||
<input type="number" min="0" max="100" x-model.number="f.max_halal_opportunity" @change="apply()" placeholder="100">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>📊 CBS demografie</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_area_data" @change="apply()"><span>Met CBS data</span></label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Min. inwoners</span>
|
||||
<input type="number" x-model.number="f.min_population" @change="apply()" placeholder="5000">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Max. inwoners</span>
|
||||
<input type="number" x-model.number="f.max_population" @change="apply()" placeholder="500000">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Min. inkomen (€)</span>
|
||||
<input type="number" x-model.number="f.min_avg_income" @change="apply()" :placeholder="meta.ranges?.min_income||''">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Max. inkomen (€)</span>
|
||||
<input type="number" x-model.number="f.max_avg_income" @change="apply()" :placeholder="meta.ranges?.max_income||''">
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
<span class="filter-label">Min. halal-markt %</span>
|
||||
<input type="number" step="0.1" x-model.number="f.min_muslim_pct" @change="apply()" placeholder="5">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>📞 Contact</summary>
|
||||
<div class="filter-group-body">
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_phone" @change="apply()"><span>Heeft telefoon</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_email" @change="apply()"><span>Heeft e-mail</span></label>
|
||||
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_manager" @change="apply()"><span>Heeft manager</span></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-sm btn-block btn-pulse" @click="apply()" :disabled="loading">Toepassen</button>
|
||||
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset filters</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-sync">
|
||||
<h4>Data sync</h4>
|
||||
<button class="btn btn-sm btn-block btn-pulse" @click="sync('contacts')" :disabled="busy">OSM contacten</button>
|
||||
<button class="btn btn-sm btn-block" @click="sync('halal')" :disabled="busy">Halal registry</button>
|
||||
<button class="btn btn-sm btn-block" @click="sync('opportunities')" :disabled="busy">Kans-scores</button>
|
||||
<button class="btn btn-sm btn-block btn-primary" @click="runEnrich()" :disabled="busy">CBS/PDOK batch</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="retail-main">
|
||||
<div x-show="mode==='stores'" class="retail-map-wrap">
|
||||
<div id="map" class="retail-map"></div>
|
||||
<div class="map-loading" x-show="loading">Laden…</div>
|
||||
</div>
|
||||
<div x-show="mode==='wholesale'" class="panel wholesale-list">
|
||||
<h3>Groothandels <span class="live-badge">OSM + contact</span> <small x-text="'('+wholesaleTotal+' totaal)'"></small></h3>
|
||||
<template x-for="w in wholesaleRows" :key="w.id">
|
||||
<div class="wholesale-item" :class="selectedWholesale?.id===w.id?'active':''" @click="selectWholesale(w)">
|
||||
<strong x-text="w.name"></strong>
|
||||
<span class="ticker-item" x-show="w.halal_certified" style="margin-left:0.35rem">Halal</span><br>
|
||||
<small x-text="(w.address||'')+', '+(w.city||'')"></small><br>
|
||||
<small x-show="w.phone" x-text="'📞 '+w.phone"></small>
|
||||
<small x-show="w.email" x-text="' · ✉ '+w.email"></small>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!wholesaleRows.length" class="muted">Geen groothandels — pas filters aan of klik Groothandels import.</p>
|
||||
</div>
|
||||
<div x-show="mode==='opportunities'" class="panel retail-table-wrap">
|
||||
<h3>Top halal-markt kansen <small x-text="'('+oppRows.length+' resultaten)'"></small></h3>
|
||||
<div class="retail-viz-row" style="display:grid;grid-template-columns:1fr 280px;gap:1rem;margin-bottom:1rem">
|
||||
<canvas id="opp-chart" height="180"></canvas>
|
||||
<div id="opp-stats" class="kpi-card"></div>
|
||||
</div>
|
||||
<table class="retail-table">
|
||||
<thead><tr><th>Score</th><th>Filiaal</th><th>Stad</th><th>Halal%</th><th>CRM</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="o in oppRows" :key="o.id">
|
||||
<tr @click="selectStore(o.id); mode='stores'" class="clickable-row">
|
||||
<td><strong x-text="Math.round(o.halal_opportunity_score||0)"></strong></td>
|
||||
<td x-text="o.chain+' · '+o.name"></td>
|
||||
<td x-text="o.city"></td>
|
||||
<td x-text="formatPct(o.muslim_proxy_pct)"></td>
|
||||
<td x-text="o.partnership_status||'—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div x-show="mode==='cities'" class="panel retail-table-wrap">
|
||||
<h3>Stad demografie <span class="live-badge">CBS</span> <small x-text="'('+cityRows.length+' steden)'"></small></h3>
|
||||
<div class="retail-viz-row" style="display:grid;grid-template-columns:1fr 280px;gap:1rem;margin-bottom:1rem">
|
||||
<canvas id="city-chart" height="180"></canvas>
|
||||
<div id="city-stats" class="kpi-card"></div>
|
||||
</div>
|
||||
<table class="retail-table">
|
||||
<thead><tr><th>Stad</th><th>Inwoners</th><th>Huishoudens</th><th>Halal-markt%</th><th>Filialen</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="c in cityRows" :key="c.id">
|
||||
<tr><td x-text="c.city"></td><td x-text="formatNum(c.population)"></td>
|
||||
<td x-text="formatNum(c.households)"></td><td x-text="formatPct(c.muslim_proxy_pct)"></td>
|
||||
<td x-text="c.store_count"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="retail-panel panel">
|
||||
<template x-if="selected && mode==='stores'">
|
||||
<div class="store-detail">
|
||||
<h3 x-text="view360.store?.name||detail.name"></h3>
|
||||
<p class="muted"><span x-text="view360.store?.chain||detail.chain"></span> · <span x-text="view360.store?.city||detail.city"></span></p>
|
||||
<p><strong x-text="view360.store?.address||detail.address"></strong></p>
|
||||
<p class="muted" x-text="(view360.store?.postcode||'')+' '+(view360.store?.province||'')"></p>
|
||||
|
||||
<div class="section-tabs">
|
||||
<button class="section-tab" :class="section==='overview'?'active':''" @click="section='overview'">Overzicht</button>
|
||||
<button class="section-tab" :class="section==='crm'?'active':''" @click="section='crm'">CRM</button>
|
||||
<button class="section-tab" :class="section==='notes'?'active':''" @click="section='notes'">Notes</button>
|
||||
<button class="section-tab" :class="section==='media'?'active':''" @click="section='media'">Media</button>
|
||||
<button class="section-tab" :class="section==='milestones'?'active':''" @click="section='milestones'">Sales</button>
|
||||
<button class="section-tab" :class="section==='ownership'?'active':''" @click="section='ownership'">Overnames</button>
|
||||
<button class="section-tab" :class="section==='weather'?'active':''" @click="section='weather'">Weer</button>
|
||||
<button class="section-tab" :class="section==='agenda'?'active':''" @click="section='agenda'">Agenda</button>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='overview'">
|
||||
<dl class="detail-dl">
|
||||
<dt>Telefoon</dt><dd x-text="detail.phone||view360.store?.phone||'—'"></dd>
|
||||
<dt>E-mail</dt><dd x-text="detail.email||view360.store?.email||'—'"></dd>
|
||||
<dt>Leidinggevende</dt><dd x-text="detail.manager_name||'—'"></dd>
|
||||
<dt>LinkedIn</dt><dd><a x-show="detail.linkedin_url" :href="detail.linkedin_url" target="_blank" x-text="detail.linkedin_url"></a><span x-show="!detail.linkedin_url">—</span></dd>
|
||||
<dt>Halal kans</dt><dd x-text="Math.round(detail.opportunity?.halal_opportunity_score||0)+'/100'"></dd>
|
||||
<dt>Stad inwoners</dt><dd x-text="formatNum(view360.catchment?.city_population)"></dd>
|
||||
<dt>Filialen in stad</dt><dd x-text="view360.catchment?.stores_in_city||0"></dd>
|
||||
<dt>Postcode bevolking</dt><dd x-text="formatNum(view360.catchment?.postcode_population_proxy)"></dd>
|
||||
</dl>
|
||||
<div class="detail-section" x-show="view360.area_analysis">
|
||||
<h4>CBS Demografie</h4>
|
||||
<dl class="detail-dl">
|
||||
<dt>Bevolking</dt><dd x-text="formatNum(view360.area_analysis.population)"></dd>
|
||||
<dt>Gem. inkomen</dt><dd x-text="formatEuro(view360.area_analysis.avg_income)"></dd>
|
||||
<dt>Halal-markt proxy</dt><dd x-text="formatPct(view360.area_analysis.religious_composition?.muslim_proxy_pct)"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-section" x-show="(detail.crm?.contacts||[]).length">
|
||||
<h4>Contactpersonen</h4>
|
||||
<template x-for="c in (detail.crm?.contacts||[])" :key="c.id">
|
||||
<div class="note-bubble">
|
||||
<strong x-text="c.full_name||c.role"></strong> · <small x-text="c.role"></small>
|
||||
<p x-show="c.phone" x-text="'📞 '+c.phone"></p>
|
||||
<p x-show="c.email" x-text="'✉ '+c.email"></p>
|
||||
<a x-show="c.linkedin_url" :href="c.linkedin_url" target="_blank">LinkedIn →</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='crm'">
|
||||
<template x-for="link in (detail.crm?.links||[])" :key="link.id">
|
||||
<p><strong x-text="link.client_name"></strong> · <span x-text="link.relationship_type"></span></p>
|
||||
</template>
|
||||
<label>Klant<select x-model.number="linkForm.client_id">
|
||||
<option value="">— kies klant —</option>
|
||||
<template x-for="c in crmOptions.clients" :key="c.id">
|
||||
<option :value="c.id" x-text="c.name"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<label>Deal<select x-model.number="linkForm.deal_id">
|
||||
<option value="">— optioneel —</option>
|
||||
<template x-for="d in crmOptions.deals" :key="d.id">
|
||||
<option :value="d.id" x-text="d.title"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<button class="btn btn-sm btn-primary btn-block btn-pulse" @click="linkCrm()" :disabled="!linkForm.client_id">Koppel aan CRM</button>
|
||||
<a class="btn btn-sm btn-block" href="/clients">Open CRM →</a>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='notes'">
|
||||
<template x-for="n in (view360.notes||[])" :key="n.id">
|
||||
<div class="note-bubble">
|
||||
<strong x-text="n.title||'Note'"></strong>
|
||||
<p x-text="n.body"></p>
|
||||
<small x-text="n.created_at"></small>
|
||||
</div>
|
||||
</template>
|
||||
<textarea x-model="noteForm.body" class="form-input" rows="3" placeholder="Nieuwe note…"></textarea>
|
||||
<input x-model="noteForm.title" class="form-input" placeholder="Titel (optioneel)">
|
||||
<button class="btn btn-sm btn-pulse btn-block" @click="addNote()" :disabled="!noteForm.body">Note opslaan</button>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='media'">
|
||||
<div class="media-grid">
|
||||
<template x-for="m in (view360.media||[])" :key="m.id">
|
||||
<img class="media-thumb" :src="m.storage_path" :alt="m.caption||m.filename">
|
||||
</template>
|
||||
</div>
|
||||
<input x-model="mediaForm.url" class="form-input" placeholder="Foto URL">
|
||||
<input x-model="mediaForm.caption" class="form-input" placeholder="Bijschrift">
|
||||
<button class="btn btn-sm btn-pulse-purple btn-block" @click="addMedia()" :disabled="!mediaForm.url">Foto toevoegen</button>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='milestones'">
|
||||
<template x-for="m in (view360.milestones||[])" :key="m.id">
|
||||
<div class="milestone-item">
|
||||
<span class="milestone-dot" :class="m.status==='completed'?'done':''"></span>
|
||||
<div><strong x-text="m.title"></strong><br>
|
||||
<small x-text="m.milestone_type+' · '+m.status+(m.value_eur?' · €'+m.value_eur:'')"></small></div>
|
||||
</div>
|
||||
</template>
|
||||
<input x-model="milestoneForm.title" class="form-input" placeholder="Milestone titel">
|
||||
<select x-model="milestoneForm.milestone_type" class="form-input">
|
||||
<option value="first_order">Eerste order</option>
|
||||
<option value="listing">Listing</option>
|
||||
<option value="promo">Promotie</option>
|
||||
<option value="renewal">Verlenging</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input type="number" x-model.number="milestoneForm.value_eur" class="form-input" placeholder="Waarde €">
|
||||
<button class="btn btn-sm btn-pulse-green btn-block" @click="addMilestone()" :disabled="!milestoneForm.title">Milestone toevoegen</button>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='ownership'">
|
||||
<template x-for="o in (view360.ownership_changes||[])" :key="o.id">
|
||||
<div class="note-bubble">
|
||||
<strong x-text="o.new_owner"></strong>
|
||||
<p x-text="'Was: '+(o.previous_owner||'?')+' · '+o.change_type"></p>
|
||||
<small x-text="o.effective_date||o.created_at"></small>
|
||||
</div>
|
||||
</template>
|
||||
<input x-model="ownershipForm.new_owner" class="form-input" placeholder="Nieuwe eigenaar">
|
||||
<input x-model="ownershipForm.previous_owner" class="form-input" placeholder="Vorige eigenaar">
|
||||
<select x-model="ownershipForm.change_type" class="form-input">
|
||||
<option value="acquisition">Overname</option>
|
||||
<option value="merger">Fusie</option>
|
||||
<option value="rebrand">Rebrand</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-block" @click="addOwnership()" :disabled="!ownershipForm.new_owner">Overname registreren</button>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='weather'">
|
||||
<p class="live-badge">7-daagse voorspelling · Open-Meteo</p>
|
||||
<div class="weather-row">
|
||||
<template x-for="d in (view360.weather_forecast||[])" :key="d.date">
|
||||
<div class="weather-day">
|
||||
<span x-text="d.date"></span>
|
||||
<strong x-text="Math.round(d.temperature_c||0)+'°'"></strong>
|
||||
<span x-text="(d.precipitation_mm||0)+'mm'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="section==='agenda'">
|
||||
<template x-for="ev in (view360.calendar||[])" :key="ev.id">
|
||||
<div class="note-bubble">
|
||||
<strong x-text="ev.title"></strong>
|
||||
<p x-text="ev.starts_at"></p>
|
||||
</div>
|
||||
</template>
|
||||
<input x-model="calendarForm.title" class="form-input" placeholder="Afspraak titel">
|
||||
<input type="datetime-local" x-model="calendarForm.starts_at" class="form-input">
|
||||
<button class="btn btn-sm btn-pulse btn-block" @click="addCalendar()" :disabled="!calendarForm.title">In agenda zetten</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="selectedWholesale && mode==='wholesale'">
|
||||
<div>
|
||||
<h3 x-text="selectedWholesale.name"></h3>
|
||||
<dl class="detail-dl">
|
||||
<dt>Adres</dt><dd x-text="selectedWholesale.address+', '+selectedWholesale.postcode+' '+selectedWholesale.city"></dd>
|
||||
<dt>Provincie</dt><dd x-text="selectedWholesale.province||'—'"></dd>
|
||||
<dt>Telefoon</dt><dd x-text="selectedWholesale.phone||'—'"></dd>
|
||||
<dt>E-mail</dt><dd x-text="selectedWholesale.email||'—'"></dd>
|
||||
<dt>Website</dt><dd><a x-show="selectedWholesale.website" :href="selectedWholesale.website" target="_blank" x-text="selectedWholesale.website"></a><span x-show="!selectedWholesale.website">—</span></dd>
|
||||
<dt>LinkedIn</dt><dd><a x-show="selectedWholesale.linkedin_url" :href="selectedWholesale.linkedin_url" target="_blank">Profiel →</a><span x-show="!selectedWholesale.linkedin_url">—</span></dd>
|
||||
</dl>
|
||||
<div class="detail-section" x-show="wholesaleContacts.length">
|
||||
<h4>Contactpersonen</h4>
|
||||
<template x-for="c in wholesaleContacts" :key="c.id">
|
||||
<div class="note-bubble">
|
||||
<strong x-text="c.full_name"></strong> · <small x-text="c.role"></small>
|
||||
<p x-show="c.phone" x-text="'📞 '+c.phone"></p>
|
||||
<p x-show="c.email" x-text="'✉ '+c.email"></p>
|
||||
<a x-show="c.linkedin_url" :href="c.linkedin_url" target="_blank">LinkedIn →</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<input x-model="whContactForm.full_name" class="form-input" placeholder="Naam contactpersoon">
|
||||
<input x-model="whContactForm.phone" class="form-input" placeholder="Telefoon">
|
||||
<input x-model="whContactForm.email" class="form-input" placeholder="E-mail">
|
||||
<input x-model="whContactForm.linkedin_url" class="form-input" placeholder="LinkedIn URL">
|
||||
<button class="btn btn-sm btn-pulse btn-block" @click="addWhContact()" :disabled="!whContactForm.full_name">Contact toevoegen</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!selected && mode==='stores'">
|
||||
<p class="muted">Selecteer een filiaal op de kaart voor de volledige 360° view.</p>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
<script>
|
||||
function retailIntel(){
|
||||
return {
|
||||
mode:'stores', section:'overview', map:null, cluster:null, loading:false, busy:false,
|
||||
stats: {{ stats | tojson }}, meta: {{ filters | tojson }}, trends: {{ trends | tojson }},
|
||||
crmOptions: {{ crm_options | tojson }},
|
||||
f:{
|
||||
chain:'',province:'',city:'',postcode_prefix:'',q:'',sort:'name',partnership:'',
|
||||
halal_gap_only:false,halal_certified:false,has_halal_section:false,has_halal_cert_registry:false,
|
||||
linked_to_crm:false,has_area_data:false,has_phone:false,has_email:false,has_manager:false,
|
||||
min_population:null,max_population:null,min_avg_income:null,max_avg_income:null,
|
||||
min_muslim_pct:null,min_halal_opportunity:null,max_halal_opportunity:null
|
||||
},
|
||||
selected:null, detail:{}, view360:{}, tableRows:[], oppRows:[], cityRows:[],
|
||||
wholesaleRows:[], wholesaleCount:0, wholesaleTotal:0, wholesaleMeta:{}, wholesaleContacts:[], selectedWholesale:null,
|
||||
wf:{ q:'', province:'', city:'', sort:'name', has_phone:false, has_email:false, halal_certified:false },
|
||||
of:{ chain:'', province:'', min_score:35, limit:100 },
|
||||
cf:{ q:'', min_population:null, min_muslim_pct:null, sort:'population', limit:200 },
|
||||
whContactForm:{ full_name:'', phone:'', email:'', linkedin_url:'' },
|
||||
oppChart:null, cityChart:null,
|
||||
rssItems:[], resultCount:0, _pollStop:null,
|
||||
linkForm:{ client_id:'', deal_id:'', partnership_status:'proposal', relationship_type:'prospect' },
|
||||
noteForm:{ title:'', body:'' }, mediaForm:{ url:'', caption:'' },
|
||||
milestoneForm:{ title:'', milestone_type:'custom', value_eur:null },
|
||||
ownershipForm:{ new_owner:'', previous_owner:'', change_type:'acquisition' },
|
||||
calendarForm:{ title:'', starts_at:'' },
|
||||
|
||||
async init(){
|
||||
this.map = L.map('map').setView([52.15,5.3],8);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(this.map);
|
||||
this.cluster = L.markerClusterGroup(); this.map.addLayer(this.cluster);
|
||||
this.loadWholesaleMeta(); this.loadRss(); this.loadWholesale(); await this.apply();
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.loadRss(); if(this.mode==='wholesale') this.loadWholesale(); }, 45000);
|
||||
},
|
||||
get activeFilterCount(){
|
||||
let n=0;
|
||||
Object.entries(this.f).forEach(([k,v])=>{
|
||||
if(v===null||v===''||v===false) return;
|
||||
if(k==='sort'&&v==='name') return;
|
||||
n++;
|
||||
});
|
||||
return n?n+' actief':'';
|
||||
},
|
||||
resetFilters(){
|
||||
this.f={ chain:'',province:'',city:'',postcode:'',q:'',sort:'name',partnership:'',
|
||||
halal_gap_only:false,halal_certified:false,has_halal_section:false,has_halal_cert_registry:false,
|
||||
linked_to_crm:false,has_area_data:false,has_phone:false,has_email:false,has_manager:false,
|
||||
min_population:null,max_population:null,min_avg_income:null,max_avg_income:null,
|
||||
min_muslim_pct:null,min_halal_opportunity:null,max_halal_opportunity:null };
|
||||
this.apply();
|
||||
},
|
||||
params(){
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(([k,v])=>{
|
||||
if(v===null||v===''||v===false) return;
|
||||
if(typeof v==='boolean') p.set(k, v?'true':'false'); else p.set(k,v);
|
||||
});
|
||||
return p.toString();
|
||||
},
|
||||
async apply(){
|
||||
this.loading=true;
|
||||
try{
|
||||
const [stats,mapData]=await Promise.all([
|
||||
fetch('/api/retail/stats?'+this.params()).then(r=>r.json()),
|
||||
fetch('/api/retail/map?'+this.params()).then(r=>r.json()),
|
||||
]);
|
||||
this.stats=stats; this.resultCount=mapData.count||0;
|
||||
this.renderMarkers(mapData.items||[]);
|
||||
}finally{ this.loading=false; }
|
||||
},
|
||||
renderMarkers(stores){
|
||||
this.cluster.clearLayers();
|
||||
const colors={'Albert Heijn':'#0066cc','Jumbo':'#ffcc00','Plus':'#008040','Lidl':'#0050aa','ALDI':'#0066b3','Dirk':'#e30613'};
|
||||
stores.forEach(s=>{
|
||||
if(!s.latitude||!s.longitude) return;
|
||||
const active=s.partnership_status==='active';
|
||||
const score=s.halal_opportunity_score||0;
|
||||
const color=active?'#22c55e':(s.halal_certified?'#a855f7':(colors[s.chain]||'#64748b'));
|
||||
const m=L.circleMarker([+s.latitude,+s.longitude],{radius:active?10:7,color,weight:2,fillColor:color,fillOpacity:0.85});
|
||||
m.on('click',()=>this.selectStore(s.id));
|
||||
m.bindTooltip(`${s.chain}: ${s.name}${score?' · kans '+Math.round(score):''}`,{direction:'top'});
|
||||
this.cluster.addLayer(m);
|
||||
});
|
||||
},
|
||||
async selectStore(id){
|
||||
const [detail, v360] = await Promise.all([
|
||||
fetch('/api/retail/supermarkets/'+id).then(r=>r.json()),
|
||||
fetch('/api/retail/360/'+id).then(r=>r.json()),
|
||||
]);
|
||||
this.detail=detail; this.view360=v360; this.selected=id; this.section='overview';
|
||||
const lat=v360.store?.latitude||detail.latitude;
|
||||
const lon=v360.store?.longitude||detail.longitude;
|
||||
if(lat) this.map.setView([+lat,+lon],14);
|
||||
},
|
||||
async loadOpportunities(){
|
||||
const p=new URLSearchParams({limit:String(this.of.limit||100),min_score:String(this.of.min_score||35)});
|
||||
if(this.of.chain) p.set('chain',this.of.chain);
|
||||
if(this.of.province) p.set('province',this.of.province);
|
||||
const d=await fetch('/api/retail/opportunities?'+p).then(r=>r.json());
|
||||
this.oppRows=d.items||[];
|
||||
this.renderOppChart();
|
||||
},
|
||||
renderOppChart(){
|
||||
if(typeof Chart==='undefined') return;
|
||||
const el=document.getElementById('opp-chart'); if(!el) return;
|
||||
const top=this.oppRows.slice(0,12);
|
||||
if(this.oppChart) this.oppChart.destroy();
|
||||
this.oppChart=new Chart(el,{type:'bar',data:{
|
||||
labels:top.map(o=>(o.chain||'')+' '+(o.city||'')),
|
||||
datasets:[{label:'Halal score',data:top.map(o=>Math.round(o.halal_opportunity_score||0)),backgroundColor:'rgba(168,85,247,0.75)',borderRadius:6}]
|
||||
},options:{indexAxis:'y',responsive:true,plugins:{legend:{display:false}}}});
|
||||
const st=document.getElementById('opp-stats');
|
||||
if(st){ const avg=top.length?Math.round(top.reduce((a,o)=>a+Number(o.halal_opportunity_score||0),0)/top.length):0;
|
||||
st.innerHTML='<span>Gem. score</span><strong>'+avg+'/100</strong><br><small>'+this.oppRows.length+' filialen</small>'; }
|
||||
},
|
||||
async loadCities(){
|
||||
const p=new URLSearchParams({limit:String(this.cf.limit||200),sort:this.cf.sort||'population'});
|
||||
if(this.cf.q) p.set('q',this.cf.q);
|
||||
if(this.cf.min_population) p.set('min_population',this.cf.min_population);
|
||||
if(this.cf.min_muslim_pct) p.set('min_muslim_pct',this.cf.min_muslim_pct);
|
||||
const d=await fetch('/api/retail/cities?'+p).then(r=>r.json());
|
||||
this.cityRows=d.items||[];
|
||||
this.renderCityChart();
|
||||
},
|
||||
renderCityChart(){
|
||||
if(typeof Chart==='undefined') return;
|
||||
const el=document.getElementById('city-chart'); if(!el) return;
|
||||
const top=this.cityRows.slice(0,15);
|
||||
if(this.cityChart) this.cityChart.destroy();
|
||||
this.cityChart=new Chart(el,{type:'bar',data:{
|
||||
labels:top.map(c=>c.city),
|
||||
datasets:[{label:'Inwoners',data:top.map(c=>Number(c.population||0)),backgroundColor:'rgba(56,189,248,0.75)',borderRadius:6}]
|
||||
},options:{responsive:true,plugins:{legend:{display:false}}}});
|
||||
const st=document.getElementById('city-stats');
|
||||
if(st){ st.innerHTML='<span>Steden</span><strong>'+this.cityRows.length+'</strong><br><small>top: '+(top[0]?.city||'—')+'</small>'; }
|
||||
},
|
||||
async loadWholesale(){
|
||||
const p = new URLSearchParams({ limit: '500', sort: this.wf.sort });
|
||||
if(this.wf.q) p.set('q', this.wf.q);
|
||||
if(this.wf.province) p.set('province', this.wf.province);
|
||||
if(this.wf.city) p.set('city', this.wf.city);
|
||||
if(this.wf.has_phone) p.set('has_phone', 'true');
|
||||
if(this.wf.has_email) p.set('has_email', 'true');
|
||||
if(this.wf.halal_certified) p.set('halal_certified', 'true');
|
||||
const d=await fetch('/api/retail/wholesalers?'+p).then(r=>r.json());
|
||||
this.wholesaleRows=d.items||[]; this.wholesaleCount=d.count||0; this.wholesaleTotal=d.total||d.count||0;
|
||||
},
|
||||
async loadWholesaleMeta(){
|
||||
try{ this.wholesaleMeta=await fetch('/api/retail/wholesalers/meta').then(r=>r.json()); }catch(e){}
|
||||
},
|
||||
resetWholesaleFilters(){
|
||||
this.wf={ q:'', province:'', city:'', sort:'name', has_phone:false, has_email:false, halal_certified:false };
|
||||
this.loadWholesale();
|
||||
},
|
||||
async selectWholesale(w){
|
||||
this.selectedWholesale=w;
|
||||
this.wholesaleContacts=[];
|
||||
if(w?.id){
|
||||
try{
|
||||
const d=await fetch('/api/retail/wholesalers/'+w.id+'/contacts').then(r=>r.json());
|
||||
this.wholesaleContacts=d.contacts||[];
|
||||
}catch(e){}
|
||||
}
|
||||
if(w.latitude) this.map.setView([+w.latitude,+w.longitude],13);
|
||||
},
|
||||
async addWhContact(){
|
||||
if(!this.selectedWholesale?.id||!this.whContactForm.full_name) return;
|
||||
await fetch('/api/retail/wholesalers/'+this.selectedWholesale.id+'/contacts',{
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(this.whContactForm)
|
||||
});
|
||||
this.whContactForm={ full_name:'', phone:'', email:'', linkedin_url:'' };
|
||||
await this.selectWholesale(this.selectedWholesale);
|
||||
},
|
||||
async loadRss(){
|
||||
try{ const d=await fetch('/api/retail/rss/live?limit=20&category=kant-en-klaar').then(r=>r.json());
|
||||
if(!(d.items||[]).length) {
|
||||
const d2=await fetch('/api/retail/rss/live?limit=20').then(r=>r.json());
|
||||
this.rssItems=d2.items||[];
|
||||
} else { this.rssItems=d.items||[]; }
|
||||
}catch(e){}
|
||||
},
|
||||
async refreshRss(){ this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadRss(); }finally{ this.busy=false; } },
|
||||
async importWholesale(){ this.busy=true; try{ await fetch('/api/retail/wholesalers/import',{method:'POST'}); await this.loadWholesale(); }finally{ this.busy=false; } },
|
||||
async syncCities(){ this.busy=true; try{ await fetch('/api/retail/cities/sync?limit=50',{method:'POST'}); await this.loadCities(); }finally{ this.busy=false; } },
|
||||
async linkCrm(){
|
||||
if(!this.selected||!this.linkForm.client_id) return;
|
||||
await fetch('/api/retail/supermarkets/'+this.selected+'/link',{
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({...this.linkForm, deal_id:this.linkForm.deal_id||null})
|
||||
});
|
||||
await this.selectStore(this.selected); await this.apply();
|
||||
},
|
||||
async addNote(){
|
||||
await fetch('/api/retail/360/'+this.selected+'/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.noteForm)});
|
||||
this.noteForm={title:'',body:''}; await this.selectStore(this.selected);
|
||||
},
|
||||
async addMedia(){
|
||||
const fn=this.mediaForm.url.split('/').pop()||'photo.jpg';
|
||||
await fetch('/api/retail/360/'+this.selected+'/media',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({filename:fn,storage_path:this.mediaForm.url,caption:this.mediaForm.caption})});
|
||||
this.mediaForm={url:'',caption:''}; await this.selectStore(this.selected);
|
||||
},
|
||||
async addMilestone(){
|
||||
await fetch('/api/retail/360/'+this.selected+'/milestones',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.milestoneForm)});
|
||||
this.milestoneForm={title:'',milestone_type:'custom',value_eur:null}; await this.selectStore(this.selected);
|
||||
},
|
||||
async addOwnership(){
|
||||
await fetch('/api/retail/360/'+this.selected+'/ownership',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.ownershipForm)});
|
||||
this.ownershipForm={new_owner:'',previous_owner:'',change_type:'acquisition'}; await this.selectStore(this.selected);
|
||||
},
|
||||
async addCalendar(){
|
||||
await fetch('/api/retail/360/'+this.selected+'/calendar',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.calendarForm)});
|
||||
this.calendarForm={title:'',starts_at:''}; await this.selectStore(this.selected);
|
||||
},
|
||||
async sync(action){ this.busy=true; try{ await fetch('/api/retail/sync/'+action,{method:'POST'}); await this.apply(); }finally{ this.busy=false; } },
|
||||
async runEnrich(){ this.busy=true; try{ await fetch('/api/retail/enrich?limit=100',{method:'POST'}); await this.apply(); }finally{ this.busy=false; } },
|
||||
formatEuro(v){ return v?'€ '+Math.round(v).toLocaleString('nl-NL'):'—'; },
|
||||
formatNum(v){ return v?Number(v).toLocaleString('nl-NL'):'—'; },
|
||||
formatPct(v){ return (v!=null&&v!=='')?Number(v).toFixed(1)+'%':'—'; },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,432 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="settingsPage()" x-init="load()">
|
||||
<header class="page-header settings-header">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p class="subtitle page-tip">Configure integrations — Herman reads active settings from PostgreSQL.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="settings-tabs">
|
||||
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
|
||||
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
|
||||
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
|
||||
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
|
||||
</nav>
|
||||
|
||||
{% if active_tab == 'email' %}
|
||||
<section class="panel settings-panel">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Email accounts</h2>
|
||||
<p class="page-tip">Kies welk mailbox Herman gebruikt om te versturen. IMAP velden zijn voor latere inbox-sync.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuw account</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-active-banner" x-show="activeAccount" x-cloak>
|
||||
<span class="badge badge-positive">Actief</span>
|
||||
<span x-text="activeAccount ? activeAccount.label + ' · ' + activeAccount.email_address : ''"></span>
|
||||
</div>
|
||||
|
||||
<div class="email-account-list">
|
||||
<template x-for="acc in accounts" :key="acc.id">
|
||||
<article class="email-account-card" :class="{ 'is-active': acc.is_active }">
|
||||
<div class="email-account-card-head">
|
||||
<div>
|
||||
<strong x-text="acc.label"></strong>
|
||||
<span class="muted" x-text="acc.email_address"></span>
|
||||
</div>
|
||||
<span class="badge" :class="acc.is_active ? 'badge-positive' : 'badge-neutral'" x-text="acc.is_active ? 'Actief' : acc.provider"></span>
|
||||
</div>
|
||||
<div class="email-account-meta">
|
||||
<span x-text="'SMTP: ' + (acc.smtp_host || '—') + ':' + (acc.smtp_port || '')"></span>
|
||||
<span x-text="acc.smtp_password_set ? '🔒 wachtwoord opgeslagen' : '⚠️ geen SMTP wachtwoord'"></span>
|
||||
<span x-show="acc.last_test_status" x-text="'Test: ' + acc.last_test_status"></span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="edit(acc)">Bewerken</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="activate(acc.id)" x-show="!acc.is_active">Maak actief</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="testAccount(acc.id)">Test SMTP</button>
|
||||
<button type="button" class="btn btn-reject btn-sm" @click="remove(acc.id)">Verwijder</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!accounts.length && !loading">Nog geen email account — voeg er een toe om Herman mail te laten versturen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
|
||||
<div class="modal-card settings-modal">
|
||||
<h3 x-text="form.id ? 'Email account bewerken' : 'Nieuw email account'"></h3>
|
||||
|
||||
<label class="form-label">Label
|
||||
<input class="form-input" x-model="form.label" placeholder="bv. Foodlinkk sales" />
|
||||
</label>
|
||||
|
||||
<label class="form-label">Email adres
|
||||
<input class="form-input" type="email" x-model="form.email_address" placeholder="info@foodlinkk.nl" />
|
||||
</label>
|
||||
|
||||
<label class="form-label">Provider (vult SMTP/IMAP automatisch in)
|
||||
<select class="form-input" x-model="form.provider" @change="applyProvider()">
|
||||
<option value="custom">Custom / eigen server</option>
|
||||
<option value="gmail">Gmail</option>
|
||||
<option value="outlook">Outlook / Microsoft 365</option>
|
||||
<option value="zoho">Zoho Mail</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="settings-form-grid">
|
||||
<label class="form-label">SMTP host<input class="form-input" x-model="form.smtp_host" /></label>
|
||||
<label class="form-label">SMTP port<input class="form-input" type="number" x-model.number="form.smtp_port" /></label>
|
||||
<label class="form-label">SMTP user<input class="form-input" x-model="form.smtp_user" /></label>
|
||||
<label class="form-label">SMTP wachtwoord
|
||||
<input class="form-input" type="password" x-model="form.smtp_password" :placeholder="form.smtp_password_set ? '•••••••• (laat leeg om te behouden)' : 'App-wachtwoord'" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="settings-details">
|
||||
<summary>IMAP (inbox sync — later)</summary>
|
||||
<div class="settings-form-grid">
|
||||
<label class="form-label">IMAP host<input class="form-input" x-model="form.imap_host" /></label>
|
||||
<label class="form-label">IMAP port<input class="form-input" type="number" x-model.number="form.imap_port" /></label>
|
||||
<label class="form-label">IMAP user<input class="form-input" x-model="form.imap_user" /></label>
|
||||
<label class="form-label">IMAP wachtwoord
|
||||
<input class="form-input" type="password" x-model="form.imap_password" :placeholder="form.imap_password_set ? '••••••••' : 'App-wachtwoord'" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" x-model="form.sync_enabled" /> Inbox sync inschakelen (wanneer beschikbaar)
|
||||
</label>
|
||||
</details>
|
||||
|
||||
<label class="form-check">
|
||||
<input type="checkbox" x-model="form.is_active" /> Direct als actief account instellen
|
||||
</label>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving">Opslaan</button>
|
||||
<button type="button" class="btn btn-secondary" @click="testForm()" :disabled="saving">Test SMTP</button>
|
||||
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif active_tab == 'permissions' %}
|
||||
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Herman — Module rechten</h2>
|
||||
<p class="page-tip">Bepaal welke bedrijfsprocessen Herman mag benaderen. Grant all = volledige toegang.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-pulse-green" @click="grantAll()">👑 Alles aan Herman</button>
|
||||
</div>
|
||||
<p><span class="live-badge">Live</span> <span x-text="grantedCount + ' / ' + total + ' modules actief'"></span></p>
|
||||
<div class="perm-grid">
|
||||
<template x-for="p in perms" :key="p.module_key">
|
||||
<div class="perm-card" :class="p.granted ? 'granted' : ''">
|
||||
<div>
|
||||
<strong x-text="p.module_label"></strong>
|
||||
<p style="font-size:0.8rem;margin:0.25rem 0;color:#94a3b8" x-text="p.description"></p>
|
||||
<small x-text="p.category"></small>
|
||||
</div>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" :checked="p.granted" @change="toggle(p.module_key, $event.target.checked)"> Toegang
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
{% elif active_tab == 'social' %}
|
||||
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Social API's</h2>
|
||||
<p class="page-tip">Configureer later je API-sleutels per platform. Zonder keys worden posts in Automatisering overgeslagen (skip-modus).</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-pulse" href="/marketing?tab=publish">→ Marketing Automatisering</a>
|
||||
</div>
|
||||
<div class="social-platform-grid">
|
||||
<template x-for="p in platforms" :key="p.key">
|
||||
<div class="perm-card" :class="p.configured ? 'granted' : ''">
|
||||
<div class="social-platform-head">
|
||||
<strong x-text="p.label"></strong>
|
||||
<span class="badge" :class="p.is_active && p.configured ? 'badge-positive' : 'badge-muted'"
|
||||
x-text="p.configured ? (p.is_active ? 'Actief' : 'Inactief') : 'Niet geconfigureerd'"></span>
|
||||
</div>
|
||||
<p class="page-tip" x-show="p.last_test_status" x-text="'Laatste test: ' + (p.last_test_status||'') + ' — ' + (p.last_test_message||'')"></p>
|
||||
<template x-for="field in p.fields" :key="field.name">
|
||||
<label class="form-label" x-text="field.label"></label>
|
||||
<input class="form-input" :type="field.secret ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
x-model="p.form[field.name]" />
|
||||
<small x-show="field.set && !p.form[field.name]" class="muted">Opgeslagen waarde blijft behouden</small>
|
||||
</template>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" x-model="p.form.is_active" /> Platform actief voor publicatie
|
||||
</label>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="save(p)" :disabled="p.saving">Opslaan</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="test(p)" :disabled="p.saving || !p.configured">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
{% elif active_tab == 'general' %}
|
||||
<section class="panel settings-panel">
|
||||
<h2>General</h2>
|
||||
<p class="page-tip">Dashboard: <a href="http://10.4.7.18:8600">http://10.4.7.18:8600</a> · Telegram: @klaploper_bot · Ochtend briefing: 07:00</p>
|
||||
<p class="page-tip">Meer instellingen (Telegram tijd, timezone) komen hier later.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function socialSettingsPage() {
|
||||
const META = {
|
||||
twitter: { label: 'X (Twitter)', fields: [
|
||||
{ name: 'api_key', label: 'API Key', secret: true },
|
||||
{ name: 'api_secret', label: 'API Secret', secret: true },
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'access_secret', label: 'Access Secret', secret: true },
|
||||
]},
|
||||
linkedin: { label: 'LinkedIn', fields: [
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'person_urn', label: 'Person URN', secret: false },
|
||||
]},
|
||||
instagram: { label: 'Instagram', fields: [
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'page_id', label: 'Page / Business ID', secret: false },
|
||||
]},
|
||||
facebook: { label: 'Facebook', fields: [
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'page_id', label: 'Page ID', secret: false },
|
||||
]},
|
||||
tiktok: { label: 'TikTok', fields: [
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'open_id', label: 'Open ID', secret: false },
|
||||
]},
|
||||
pinterest: { label: 'Pinterest', fields: [
|
||||
{ name: 'access_token', label: 'Access Token', secret: true },
|
||||
{ name: 'board_id', label: 'Board ID', secret: false },
|
||||
]},
|
||||
};
|
||||
|
||||
return {
|
||||
platforms: [],
|
||||
async load() {
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/social');
|
||||
const items = r.items || [];
|
||||
const byKey = {};
|
||||
items.forEach(i => { byKey[i.platform] = i; });
|
||||
this.platforms = (r.platforms || Object.keys(META)).map(key => {
|
||||
const row = byKey[key] || {};
|
||||
const cfg = row.config || {};
|
||||
const meta = META[key] || { label: key, fields: [] };
|
||||
const form = { is_active: !!row.is_active };
|
||||
meta.fields.forEach(f => {
|
||||
form[f.name] = '';
|
||||
f.set = !!cfg[f.name + '_set'];
|
||||
f.placeholder = f.set ? '••••••••' : '';
|
||||
});
|
||||
const configured = meta.fields.every(f => f.set || form[f.name]);
|
||||
return {
|
||||
key, label: meta.label, fields: meta.fields, form,
|
||||
is_active: !!row.is_active,
|
||||
configured: meta.fields.every(f => cfg[f.name + '_set']),
|
||||
last_test_status: row.last_test_status,
|
||||
last_test_message: row.last_test_message,
|
||||
saving: false,
|
||||
};
|
||||
});
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async save(p) {
|
||||
p.saving = true;
|
||||
try {
|
||||
const body = { is_active: p.form.is_active };
|
||||
p.fields.forEach(f => {
|
||||
if (p.form[f.name]) body[f.name] = p.form[f.name];
|
||||
});
|
||||
await Cockpit.api('/api/settings/social/' + p.key, { method: 'PUT', body: JSON.stringify(body) });
|
||||
Cockpit.toast(p.label + ' opgeslagen', 'success');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
p.saving = false;
|
||||
},
|
||||
async test(p) {
|
||||
p.saving = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/social/' + p.key + '/test', { method: 'POST' });
|
||||
Cockpit.toast(r.message || (r.ok ? 'OK' : 'Mislukt'), r.ok ? 'success' : 'error');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
p.saving = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function permissionsPage() {
|
||||
return {
|
||||
perms: [], grantedCount: 0, total: 0,
|
||||
async loadPerms() {
|
||||
const r = await Cockpit.api('/api/settings/permissions');
|
||||
this.perms = r.items || [];
|
||||
this.grantedCount = r.granted_count || 0;
|
||||
this.total = r.total || 0;
|
||||
},
|
||||
async toggle(key, granted) {
|
||||
await Cockpit.api('/api/settings/permissions/' + key, { method: 'PUT', body: JSON.stringify({ granted }) });
|
||||
await this.loadPerms();
|
||||
},
|
||||
async grantAll() {
|
||||
const r = await Cockpit.api('/api/settings/permissions/grant-all', { method: 'POST' });
|
||||
Cockpit.toast(r.message || 'Alle rechten toegekend', 'success');
|
||||
await this.loadPerms();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% if active_tab == 'email' %}
|
||||
<script>
|
||||
const PROVIDERS = {
|
||||
gmail: { smtp_host: 'smtp.gmail.com', smtp_port: 587, imap_host: 'imap.gmail.com', imap_port: 993 },
|
||||
outlook: { smtp_host: 'smtp.office365.com', smtp_port: 587, imap_host: 'outlook.office365.com', imap_port: 993 },
|
||||
zoho: { smtp_host: 'smtp.zoho.eu', smtp_port: 587, imap_host: 'imap.zoho.eu', imap_port: 993 },
|
||||
custom: { smtp_host: '', smtp_port: 587, imap_host: '', imap_port: 993 },
|
||||
};
|
||||
|
||||
function settingsPage() {
|
||||
return {
|
||||
accounts: [],
|
||||
activeAccount: null,
|
||||
loading: true,
|
||||
formOpen: false,
|
||||
saving: false,
|
||||
form: defaultForm(),
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/email');
|
||||
this.accounts = r.accounts || [];
|
||||
this.activeAccount = this.accounts.find(a => a.is_active) || null;
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
defaultForm() { return defaultForm(); },
|
||||
|
||||
openForm() {
|
||||
this.form = defaultForm();
|
||||
this.formOpen = true;
|
||||
},
|
||||
|
||||
edit(acc) {
|
||||
this.form = Object.assign(defaultForm(), acc, { smtp_password: '', imap_password: '' });
|
||||
this.formOpen = true;
|
||||
},
|
||||
|
||||
applyProvider() {
|
||||
const p = PROVIDERS[this.form.provider] || PROVIDERS.custom;
|
||||
this.form.smtp_host = p.smtp_host;
|
||||
this.form.smtp_port = p.smtp_port;
|
||||
this.form.imap_host = p.imap_host;
|
||||
this.form.imap_port = p.imap_port;
|
||||
if (this.form.email_address && !this.form.smtp_user) {
|
||||
this.form.smtp_user = this.form.email_address;
|
||||
this.form.imap_user = this.form.email_address;
|
||||
}
|
||||
},
|
||||
|
||||
bodyFromForm() {
|
||||
const b = {
|
||||
label: this.form.label,
|
||||
email_address: this.form.email_address,
|
||||
provider: this.form.provider,
|
||||
is_active: this.form.is_active,
|
||||
smtp_host: this.form.smtp_host,
|
||||
smtp_port: this.form.smtp_port,
|
||||
smtp_user: this.form.smtp_user,
|
||||
imap_host: this.form.imap_host,
|
||||
imap_port: this.form.imap_port,
|
||||
imap_user: this.form.imap_user,
|
||||
sync_enabled: this.form.sync_enabled,
|
||||
};
|
||||
if (this.form.smtp_password) b.smtp_password = this.form.smtp_password;
|
||||
if (this.form.imap_password) b.imap_password = this.form.imap_password;
|
||||
return b;
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const body = this.bodyFromForm();
|
||||
if (this.form.id) {
|
||||
await Cockpit.api('/api/settings/email/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await Cockpit.api('/api/settings/email', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
Cockpit.toast('Opgeslagen', 'success');
|
||||
this.formOpen = false;
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async activate(id) {
|
||||
try {
|
||||
await Cockpit.api('/api/settings/email/' + id + '/activate', { method: 'POST' });
|
||||
Cockpit.toast('Actief account gewijzigd', 'success');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async testAccount(id) {
|
||||
try {
|
||||
Cockpit.toast('SMTP test…', 'info');
|
||||
const r = await Cockpit.api('/api/settings/email/' + id + '/test', { method: 'POST' });
|
||||
Cockpit.toast(r.message || 'SMTP OK', r.ok ? 'success' : 'error');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async testForm() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/settings/email/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.bodyFromForm()),
|
||||
});
|
||||
Cockpit.toast(r.message || 'SMTP OK', r.ok ? 'success' : 'error');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
if (!confirm('Email account verwijderen?')) return;
|
||||
try {
|
||||
await Cockpit.api('/api/settings/email/' + id, { method: 'DELETE' });
|
||||
Cockpit.toast('Verwijderd', 'success');
|
||||
await this.load();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null, label: '', email_address: '', provider: 'gmail', is_active: true,
|
||||
smtp_host: 'smtp.gmail.com', smtp_port: 587, smtp_user: '', smtp_password: '', smtp_password_set: false,
|
||||
imap_host: 'imap.gmail.com', imap_port: 993, imap_user: '', imap_password: '', imap_password_set: false,
|
||||
sync_enabled: false,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AI Studio · ComfyUI{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.studio-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
|
||||
@media (max-width: 1000px) { .studio-grid { grid-template-columns: 1fr; } }
|
||||
.studio-preview {
|
||||
min-height: 420px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,.35); border: 1px solid rgba(148,163,184,.15);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.studio-preview img { max-width: 100%; max-height: 70vh; border-radius: 8px; }
|
||||
.progress-bar {
|
||||
height: 8px; background: rgba(148,163,184,.15); border-radius: 999px; overflow: hidden;
|
||||
margin: .75rem 0;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: linear-gradient(90deg, #22d3ee, #a855f7);
|
||||
transition: width .4s ease;
|
||||
}
|
||||
.event-log {
|
||||
max-height: 280px; overflow-y: auto; font-family: ui-monospace, monospace;
|
||||
font-size: .78rem; background: rgba(0,0,0,.35); border-radius: 8px; padding: .6rem;
|
||||
border: 1px solid rgba(148,163,184,.12);
|
||||
}
|
||||
.event-log div { padding: .15rem 0; color: var(--text-secondary); }
|
||||
.quality-pills { display: flex; gap: .5rem; flex-wrap: wrap; margin: .75rem 0; }
|
||||
.quality-pill {
|
||||
padding: .35rem .75rem; border-radius: 999px; cursor: pointer; font-size: .85rem;
|
||||
border: 1px solid rgba(148,163,184,.2); background: rgba(0,0,0,.2);
|
||||
}
|
||||
.quality-pill.active { border-color: var(--accent-cyan); color: var(--accent-cyan); }
|
||||
.studio-links { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .75rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="aiStudio()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>AI Studio</h1>
|
||||
<p class="subtitle page-tip">ComfyUI — prompt, live voortgang, HD export. CPU: 3–10 min per HD foto.</p>
|
||||
</header>
|
||||
|
||||
<div class="studio-grid">
|
||||
<section class="panel">
|
||||
<h2 style="font-size:1rem;margin:0 0 .75rem;">Positieve prompt</h2>
|
||||
<textarea class="form-input" rows="3" x-model="prompt"
|
||||
placeholder="bijv. premium halal kipnuggets productfoto, studio softbox, witte achtergrond, 8k commercial"></textarea>
|
||||
|
||||
<h2 style="font-size:1rem;margin:1rem 0 .75rem;">Negatieve prompt</h2>
|
||||
<textarea class="form-input" rows="2" x-model="negativePrompt"
|
||||
placeholder="blurry, low quality, watermark, text, ugly, deformed, bad anatomy"></textarea>
|
||||
|
||||
<div class="quality-pills">
|
||||
<button type="button" class="quality-pill" :class="{active: quality==='fast'}" @click="quality='fast'">Snel · 512px</button>
|
||||
<button type="button" class="quality-pill" :class="{active: quality==='hd'}" @click="quality='hd'">HD · 1024px</button>
|
||||
<button type="button" class="quality-pill" :class="{active: quality==='ultra'}" @click="quality='ultra'">Ultra · 1024px+</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
||||
<button class="btn btn-primary" @click="start()" :disabled="running || !prompt.trim()">
|
||||
<span x-show="!running">Genereer HD foto</span>
|
||||
<span x-show="running">Bezig…</span>
|
||||
</button>
|
||||
<a class="btn btn-ghost" href="http://10.4.7.18:8188" target="_blank" rel="noopener">ComfyUI backend ↗</a>
|
||||
</div>
|
||||
|
||||
<div x-show="running || percent > 0" style="margin-top:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.85rem;">
|
||||
<span x-text="statusLabel"></span>
|
||||
<span x-text="percent + '%'"></span>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" :style="'width:' + percent + '%'"></div></div>
|
||||
<p class="page-tip" x-text="statusMessage" style="margin:0;"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2 style="font-size:1rem;margin:0 0 .75rem;">Live log</h2>
|
||||
<div class="event-log">
|
||||
<template x-for="(ev, i) in events" :key="i">
|
||||
<div x-text="ev"></div>
|
||||
</template>
|
||||
<div x-show="!events.length" class="page-tip">Start een generatie om ComfyUI live te volgen.</div>
|
||||
</div>
|
||||
<div class="studio-links" x-show="imageUrl">
|
||||
<a class="btn btn-ghost" :href="imageUrl" target="_blank" download>Download</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:1rem;">
|
||||
<div class="studio-preview">
|
||||
<img x-show="imageUrl" :src="imageUrl" alt="Gegenereerde afbeelding" />
|
||||
<p x-show="!imageUrl && !running" class="page-tip">Nog geen afbeelding — vul prompt in en klik Genereer.</p>
|
||||
<p x-show="running && !imageUrl" class="page-tip">ComfyUI werkt op de achtergrond…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function aiStudio() {
|
||||
return {
|
||||
prompt: '',
|
||||
negativePrompt: 'blurry, low quality, watermark, text, ugly, deformed, bad anatomy, oversaturated',
|
||||
quality: 'hd',
|
||||
running: false,
|
||||
promptId: null,
|
||||
pollTimer: null,
|
||||
percent: 0,
|
||||
statusLabel: '',
|
||||
statusMessage: '',
|
||||
events: [],
|
||||
imageUrl: '',
|
||||
init() {},
|
||||
async start() {
|
||||
if (!this.prompt.trim() || this.running) return;
|
||||
this.running = true;
|
||||
this.percent = 0;
|
||||
this.events = [];
|
||||
this.imageUrl = '';
|
||||
this.statusLabel = 'Starten…';
|
||||
this.statusMessage = 'ComfyUI queue';
|
||||
try {
|
||||
const r = await Cockpit.api('/api/ai/generate-image/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: this.prompt.trim(), negative_prompt: this.negativePrompt.trim(), quality: this.quality })
|
||||
});
|
||||
this.promptId = r.prompt_id;
|
||||
this.events = r.events || ['Gestart'];
|
||||
this.statusLabel = r.quality_label || this.quality;
|
||||
this.poll();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.running = false;
|
||||
}
|
||||
},
|
||||
poll() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.pollTimer = setInterval(async () => {
|
||||
if (!this.promptId) return;
|
||||
try {
|
||||
const j = await Cockpit.api('/api/ai/generate-image/progress/' + this.promptId);
|
||||
this.percent = j.percent || 0;
|
||||
this.statusMessage = j.message || j.status || '';
|
||||
this.statusLabel = (j.quality || this.quality) + ' · ' + (j.status || '');
|
||||
if (j.events && j.events.length) this.events = j.events;
|
||||
if (j.status === 'done' && j.proxy_url) {
|
||||
this.imageUrl = j.proxy_url;
|
||||
this.percent = 100;
|
||||
this.running = false;
|
||||
clearInterval(this.pollTimer);
|
||||
Cockpit.toast('HD foto klaar', 'success');
|
||||
}
|
||||
if (j.status === 'error') {
|
||||
this.running = false;
|
||||
clearInterval(this.pollTimer);
|
||||
Cockpit.toast(j.error || 'ComfyUI fout', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
this.running = false;
|
||||
clearInterval(this.pollTimer);
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="suppliersCrud()">
|
||||
<header class="page-header"><h1>Suppliers</h1><p class="subtitle page-tip">Vendor registry with ratings and lead times.</p></header>
|
||||
<button class="btn btn-primary" @click="openNew()">Add supplier</button>
|
||||
<table class="data-table"><thead><tr><th>Name</th><th>Country</th><th>Rating</th><th></th></tr></thead>
|
||||
<tbody>{% for s in suppliers %}<tr class="clickable-row" @click="edit({{ s.id }}, '{{ s.name|e }}', '{{ (s.country or '')|e }}', {{ s.rating or 0 }})">
|
||||
<td>{{ s.name }}</td><td>{{ s.country }}</td><td>{{ s.rating }}</td>
|
||||
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ s.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
|
||||
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.country" /><input type="number" step="0.1" class="form-input" x-model="form.rating" />
|
||||
<button class="btn btn-primary" @click="save()">Save</button></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function suppliersCrud() {
|
||||
return { show: false, id: null, form: { name: '', country: '', rating: 4, category: '', moq: null, lead_time_days: null, contact: '' },
|
||||
openNew() { this.id=null; this.form={ name:'', country:'', rating:4, category:'', moq:null, lead_time_days:null, contact:'' }; this.show=true; },
|
||||
edit(id,name,country,rating) { this.id=id; this.form={name,country,rating,category:'',moq:null,lead_time_days:null,contact:''}; this.show=true; },
|
||||
async save() {
|
||||
const body = { ...this.form, rating: parseFloat(this.form.rating) };
|
||||
if (this.id) await Cockpit.api('/suppliers/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
},
|
||||
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/suppliers/' + id, { method: 'DELETE' }); location.reload(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
:root {
|
||||
--bg-root: #0b0f14;
|
||||
--bg-surface: #111820;
|
||||
--bg-elevated: #161e28;
|
||||
--bg-hover: #1c2633;
|
||||
--border-subtle: #243041;
|
||||
--border-strong: #334155;
|
||||
--text-primary: #e8eef5;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-cyan-dim: rgba(34, 211, 238, 0.15);
|
||||
--accent-purple: #a78bfa;
|
||||
--accent-purple-dim: rgba(167, 139, 250, 0.15);
|
||||
--accent-amber: #fbbf24;
|
||||
--accent-amber-dim: rgba(251, 191, 36, 0.15);
|
||||
--accent-green: #34d399;
|
||||
--accent-green-dim: rgba(52, 211, 153, 0.15);
|
||||
--accent-red: #f87171;
|
||||
--accent-red-dim: rgba(248, 113, 113, 0.15);
|
||||
--shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Neo topnav — unieke kleur per tab + glow pill effect */
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.4rem 0.85rem;
|
||||
padding-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
/* Shared pill base */
|
||||
.nav-pill,
|
||||
.topnav-herman.nav-pill {
|
||||
--pill-color: #38bdf8;
|
||||
--pill-glow: rgba(56, 189, 248, 0.45);
|
||||
--pill-bg: rgba(56, 189, 248, 0.12);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
color: #cbd5e1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s, color 0.2s;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.nav-pill::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, var(--pill-bg), transparent 70%);
|
||||
opacity: 0.85;
|
||||
z-index: -1;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.nav-pill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -60%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
transform: skewX(-20deg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, left 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-pill:hover {
|
||||
transform: translateY(-2px);
|
||||
color: #f8fafc;
|
||||
border-color: color-mix(in srgb, var(--pill-color) 50%, transparent);
|
||||
box-shadow: 0 4px 20px var(--pill-glow), 0 0 0 1px color-mix(in srgb, var(--pill-color) 25%, transparent);
|
||||
}
|
||||
|
||||
.nav-pill:hover::after {
|
||||
opacity: 1;
|
||||
left: 120%;
|
||||
}
|
||||
|
||||
.nav-pill.active {
|
||||
color: #fff;
|
||||
border-color: var(--pill-color);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent),
|
||||
0 0 22px var(--pill-glow),
|
||||
inset 0 0 18px color-mix(in srgb, var(--pill-color) 18%, transparent);
|
||||
text-shadow: 0 0 12px var(--pill-glow);
|
||||
animation: nav-pill-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.nav-pill.active::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--pill-color) 35%, transparent), color-mix(in srgb, var(--pill-color) 8%, transparent));
|
||||
}
|
||||
|
||||
@keyframes nav-pill-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), 0 0 18px var(--pill-glow), inset 0 0 14px color-mix(in srgb, var(--pill-color) 15%, transparent); }
|
||||
50% { box-shadow: 0 0 0 1px var(--pill-color), 0 0 28px var(--pill-glow), inset 0 0 22px color-mix(in srgb, var(--pill-color) 22%, transparent); }
|
||||
}
|
||||
|
||||
/* Herman — reset legacy gold tab, use neo pill */
|
||||
.topnav-herman.nav-pill {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topnav-herman.nav-pill.active {
|
||||
color: #fff;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.nav-pill-herman {
|
||||
--pill-color: #fbbf24;
|
||||
--pill-glow: rgba(251, 191, 36, 0.55);
|
||||
--pill-bg: rgba(251, 191, 36, 0.2);
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
}
|
||||
|
||||
/* CRM */
|
||||
.nav-pill-clients { --pill-color: #38bdf8; --pill-glow: rgba(56, 189, 248, 0.5); --pill-bg: rgba(56, 189, 248, 0.15); }
|
||||
.nav-pill-deals { --pill-color: #4ade80; --pill-glow: rgba(74, 222, 128, 0.5); --pill-bg: rgba(74, 222, 128, 0.12); }
|
||||
.nav-pill-products { --pill-color: #a855f7; --pill-glow: rgba(168, 85, 247, 0.5); --pill-bg: rgba(168, 85, 247, 0.12); }
|
||||
.nav-pill-suppliers { --pill-color: #fb923c; --pill-glow: rgba(251, 146, 60, 0.5); --pill-bg: rgba(251, 146, 60, 0.12); }
|
||||
|
||||
/* Intel */
|
||||
.nav-pill-beurs { --pill-color: #b8ff3c; --pill-glow: rgba(184, 255, 60, 0.45); --pill-bg: rgba(184, 255, 60, 0.1); }
|
||||
.nav-pill-retail { --pill-color: #0066cc; --pill-glow: rgba(0, 102, 204, 0.55); --pill-bg: rgba(0, 102, 204, 0.18); }
|
||||
.nav-pill-browser { --pill-color: #14b8a6; --pill-glow: rgba(20, 184, 166, 0.5); --pill-bg: rgba(20, 184, 166, 0.12); }
|
||||
.nav-pill-documents { --pill-color: #94a3b8; --pill-glow: rgba(148, 163, 184, 0.4); --pill-bg: rgba(148, 163, 184, 0.1); }
|
||||
|
||||
/* Hermes */
|
||||
.nav-pill-telegram { --pill-color: #2aabee; --pill-glow: rgba(42, 171, 238, 0.55); --pill-bg: rgba(42, 171, 238, 0.15); }
|
||||
|
||||
/* Agents */
|
||||
.nav-pill-agents { --pill-color: #00e5ff; --pill-glow: rgba(0, 229, 255, 0.5); --pill-bg: rgba(0, 229, 255, 0.1); }
|
||||
.nav-pill-marketing { --pill-color: #ff6b9d; --pill-glow: rgba(255, 107, 157, 0.5); --pill-bg: rgba(255, 107, 157, 0.12); }
|
||||
.nav-pill-chat { --pill-color: #fcd34d; --pill-glow: rgba(252, 211, 77, 0.45); --pill-bg: rgba(252, 211, 77, 0.12); }
|
||||
.nav-pill-studio { --pill-color: #ec4899; --pill-glow: rgba(236, 72, 153, 0.5); --pill-bg: rgba(236, 72, 153, 0.12); }
|
||||
.nav-pill-reports { --pill-color: #6366f1; --pill-glow: rgba(99, 102, 241, 0.5); --pill-bg: rgba(99, 102, 241, 0.12); }
|
||||
|
||||
/* System */
|
||||
.nav-pill-analytics { --pill-color: #8b5cf6; --pill-glow: rgba(139, 92, 246, 0.5); --pill-bg: rgba(139, 92, 246, 0.12); }
|
||||
.nav-pill-voice { --pill-color: #f97316; --pill-glow: rgba(249, 115, 22, 0.5); --pill-bg: rgba(249, 115, 22, 0.12); }
|
||||
.nav-pill-settings { --pill-color: #e2e8f0; --pill-glow: rgba(226, 232, 240, 0.35); --pill-bg: rgba(226, 232, 240, 0.08); }
|
||||
|
||||
/* Group labels — subtiel gekleurd */
|
||||
.topnav-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.topnav-group:first-of-type { border-left: none; }
|
||||
.topnav-group-system { margin-left: auto; }
|
||||
|
||||
.topnav-label {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748b;
|
||||
margin-right: 0.15rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.topnav-group-crm .topnav-label { color: #38bdf8; }
|
||||
.topnav-group-intel .topnav-label { color: #b8ff3c; }
|
||||
.topnav-group-hermes .topnav-label { color: #2aabee; }
|
||||
.topnav-group-agents .topnav-label { color: #00e5ff; }
|
||||
|
||||
/* Override legacy palantir topnav link styles */
|
||||
.topnav-group a.nav-pill {
|
||||
margin: 0;
|
||||
}
|
||||
.topnav-group a.nav-pill:hover,
|
||||
.topnav-group a.nav-pill.active {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0.65rem 1.25rem 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topnav-label { display: none; }
|
||||
.nav-pill { padding: 0.4rem 0.65rem; font-size: 0.75rem; }
|
||||
.topnav-group { padding: 0.25rem 0.4rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-pill.active { animation: none; }
|
||||
.nav-pill:hover { transform: none; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div x-data="voicePage()">
|
||||
<header class="page-header">
|
||||
<h1>Voice · Whisper</h1>
|
||||
<p class="subtitle page-tip">Spreek of upload audio — Whisper transcribeert, Herman antwoordt. Ook via Telegram spraakbericht 🎤</p>
|
||||
</header>
|
||||
<section class="panel">
|
||||
<div class="browser-action-bar">
|
||||
<button type="button" class="btn btn-primary" @click="toggleRecord()" x-text="recording ? '⏹ Stop opname' : '🎤 Live opnemen'"></button>
|
||||
<input type="file" accept="audio/*" @change="onFile($event)" />
|
||||
<button class="btn btn-secondary" @click="transcribe()" :disabled="!file && !recordedBlob">Transcribeer</button>
|
||||
<button class="btn btn-primary" @click="sendHerman()" :disabled="!text">→ Herman</button>
|
||||
</div>
|
||||
<textarea class="form-input" rows="6" x-model="text" placeholder="Transcript verschijnt hier…"></textarea>
|
||||
<pre class="browser-preview-text" x-show="reply" x-text="reply"></pre>
|
||||
</section>
|
||||
<ul>{% for ev in voice_events %}<li>{{ ev.title }} — {{ ev.created_at }}</li>{% else %}<li class="empty-state">Nog geen voice events.</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function voicePage() {
|
||||
return {
|
||||
file: null, text: '', reply: '', recording: false, recordedBlob: null, mediaRecorder: null, chunks: [],
|
||||
onFile(e) { this.file = e.target.files[0]; this.recordedBlob = null; },
|
||||
async toggleRecord() {
|
||||
if (this.recording && this.mediaRecorder) {
|
||||
this.mediaRecorder.stop(); this.recording = false; return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.chunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
this.mediaRecorder.ondataavailable = e => this.chunks.push(e.data);
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.recordedBlob = new Blob(this.chunks, { type: 'audio/webm' });
|
||||
this.file = new File([this.recordedBlob], 'live.webm', { type: 'audio/webm' });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
Cockpit.toast('Opname klaar — klik Transcribeer', 'success');
|
||||
};
|
||||
this.mediaRecorder.start(); this.recording = true;
|
||||
} catch (e) { Cockpit.toast('Microfoon geweigerd: ' + e.message, 'error'); }
|
||||
},
|
||||
async transcribe() {
|
||||
const f = this.file;
|
||||
if (!f) return;
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
const r = await fetch('/api/voice/transcribe', { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || 'Transcribe failed');
|
||||
this.text = j.text || j.transcript || '';
|
||||
Cockpit.toast('Whisper klaar', 'success');
|
||||
},
|
||||
async sendHerman() {
|
||||
const r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify({ message: this.text }) });
|
||||
this.reply = r.reply || JSON.stringify(r);
|
||||
Cockpit.toast('Herman antwoordde', 'success');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user