Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.

Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
This commit is contained in:
Aissa
2026-07-19 18:25:01 +00:00
parent 544d9e611d
commit 4dd1b8265f
25 changed files with 2757 additions and 108 deletions
+161
View File
@@ -0,0 +1,161 @@
import json
from datetime import date, datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import (
build_live_digest,
collect_briefing_data,
generate_daily_briefing,
serialize_stats,
stream_daily_briefing,
)
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("/server-time")
async def server_time():
now = datetime.utcnow()
return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"}
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
bookmarks = []
bookmark_map = {}
try:
bookmarks = fetch_all(
"""SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at
FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30"""
)
for b in bookmarks:
if b.get("created_at") and hasattr(b["created_at"], "isoformat"):
b["created_at"] = b["created_at"].isoformat()
bookmark_map[b["rss_item_id"]] = True
except Exception:
bookmarks = []
stats["rss_bookmarks"] = bookmarks
stats["rss_bookmark_ids"] = list(bookmark_map.keys())
if not stats.get("rss_live") and not stats.get("trending_food"):
try:
stats["rss_live"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
)
except Exception:
stats["rss_live"] = []
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "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.post("/herman/briefing/stream")
async def herman_briefing_stream():
"""SSE stream — live stappen tijdens dagrapport generatie."""
async def event_gen():
try:
async for event in stream_daily_briefing():
yield f"data: {json.dumps(event, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@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("/live/platform")
async def live_platform(limit: int = 100, agent: Optional[str] = None):
from app.services.platform_live import fetch_platform_events, platform_stats
events = fetch_platform_events(limit=min(limit, 200), agent=agent)
return {"ok": True, "stats": platform_stats(), "events": events}
@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}
+19 -4
View File
@@ -9,7 +9,9 @@ from fastapi.responses import StreamingResponse
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import (
build_live_digest,
collect_briefing_data,
format_live_digest_text,
generate_daily_briefing,
serialize_stats,
stream_daily_briefing,
@@ -57,9 +59,9 @@ async def server_time():
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
async def herman_briefing_stats(light: bool = False):
"""Live stats from DB — light=1 skips halal engine for snelle poll-refresh."""
stats = serialize_stats(collect_briefing_data(skip_halal=light))
bookmarks = []
bookmark_map = {}
try:
@@ -86,7 +88,20 @@ async def herman_briefing_stats():
)
except Exception:
stats["rss_live"] = []
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "at": datetime.utcnow().isoformat()}
@router.get("/herman/briefing/live-text")
async def herman_briefing_live_text():
"""Compacte live samenvatting voor Telegram / bots."""
stats = serialize_stats(collect_briefing_data())
digest = build_live_digest(stats)
return {
"ok": True,
"text": format_live_digest_text(digest),
"live_digest": digest,
"at": datetime.utcnow().isoformat(),
}
@router.post("/herman/briefing")
+9 -3
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
@@ -32,8 +32,14 @@ def _safe_sum(table: str, column: str, where: str = "") -> float:
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}
"""Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
live_stats = serialize_stats(collect_briefing_data())
payload: dict = {
"content": None,
"stats": live_stats,
"live_digest": build_live_digest(live_stats),
"created_at": None,
}
if not briefing:
return payload
+26 -2
View File
@@ -85,8 +85,10 @@ async def api_entity(entity_id: int):
@router.get("/api/export-intel/contacts")
async def api_contacts(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
has_email: Optional[bool] = None,
crm_linked: Optional[bool] = None,
q: Optional[str] = None,
limit: int = 200,
offset: int = 0,
@@ -94,18 +96,40 @@ async def api_contacts(
params: dict[str, Any] = {"limit": limit, "offset": offset}
if country:
params["country"] = country
if region:
params["region"] = region
if entity_type:
params["entity_type"] = entity_type
if has_email is not None:
params["has_email"] = has_email
if crm_linked is not None:
params["crm_linked"] = crm_linked
if q:
params["q"] = q
return await _proxy("GET", "/contacts", params=params)
@router.get("/api/export-intel/contacts/export.csv")
async def api_contacts_export(country: Optional[str] = None, entity_type: Optional[str] = None):
params = {k: v for k, v in {"country": country, "entity_type": entity_type}.items() if v}
async def api_contacts_export(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
has_email: Optional[bool] = None,
crm_linked: Optional[bool] = None,
q: Optional[str] = None,
):
params = {
k: v
for k, v in {
"country": country,
"region": region,
"entity_type": entity_type,
"has_email": has_email,
"crm_linked": crm_linked,
"q": q,
}.items()
if v is not None and v != ""
}
url = f"{TOOLS}/export-intel/contacts/export.csv"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(url, params=params)
+11
View File
@@ -34,6 +34,17 @@ def register_recommendation_routes(router: APIRouter) -> None:
async def reco_dismiss(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/dismiss")
@router.get("/recommendations/halal/live")
async def halal_reco_live(limit: int = 5, refresh: bool = False):
q = f"/recommendations/halal/live?limit={limit}"
if refresh:
q += "&refresh=true"
return await _proxy("GET", q)
@router.post("/recommendations/halal/refresh")
async def halal_reco_refresh(limit: int = 5):
return await _proxy("POST", f"/recommendations/halal/refresh?limit={limit}")
@router.post("/research/run")
async def research_run():
return await _proxy("POST", "/research/run")
+310 -32
View File
@@ -2,9 +2,12 @@ from __future__ import annotations
import asyncio
import json
import re
from datetime import date, datetime, timezone
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import market_stocks, llm_router
@@ -362,7 +365,57 @@ def _collect_rss_market(data: dict[str, Any]) -> None:
data["rss_items"] = _safe_count("rss_items")
def collect_briefing_data() -> dict[str, Any]:
def _classify_revenue_brand(name: str) -> str:
n = (name or "").lower()
if any(k in n for k in ("foodlinkk", "linknbit", "linknbi", "wereldexport")):
return "foodlinkk"
if "cucina" in n and "foodlinkk" not in n:
return "cucina"
chains = ("ah", "jumbo", "picnic", "plus", "vomar", "dirk", "hoogvliet", "deka", "spar", "doner")
if any(n == c or n.startswith(c + " ") for c in chains):
return "cucina"
if any(k in n for k in ("foodservice", "snack", "diepvries", "halal", "gas stunning", "marketing budget adds cucina")):
return "cucina"
if any(k in n for k in ("fresh supplier", "boerschappen", "subsidies cucina")):
return "foodlinkk"
if any(k in n for k in ("total earnings", "loonkosten", "total expenses", "verkopen cucina")):
return "shared"
return "other"
def _collect_revenue_cockpit(data: dict[str, Any]) -> None:
try:
from app.services import revenue_cockpit as rc
projects = rc.list_projects()
stats = rc.dashboard_stats()
by_brand: dict[str, list] = {"cucina": [], "foodlinkk": [], "shared": [], "other": []}
for p in projects:
if (p.get("status") or "") != "active":
continue
brand = _classify_revenue_brand(p.get("name") or "")
by_brand[brand].append(p)
data["revenue"] = {
"stats": stats,
"by_brand": {
k: sorted(v, key=lambda x: float(x.get("margin_month") or 0), reverse=True)[:6]
for k, v in by_brand.items()
},
}
except Exception:
data["revenue"] = {}
def _collect_export_intel(data: dict[str, Any]) -> None:
try:
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/stats"
r = httpx.get(url, timeout=4.0)
data["export_intel"] = r.json() if r.status_code == 200 else {}
except Exception:
data["export_intel"] = {}
def collect_briefing_data(*, skip_halal: bool = False) -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
@@ -374,10 +427,37 @@ def collect_briefing_data() -> dict[str, Any]:
_collect_nas_analytics(data)
_collect_retail_intel(data)
_collect_rss_market(data)
_collect_revenue_cockpit(data)
_collect_export_intel(data)
if not skip_halal:
_collect_halal_recommendations(data)
data["activity_log"] = _build_activity_log(data)
return data
def _collect_halal_recommendations(data: dict[str, Any]) -> None:
try:
url = f"{settings.TOOLS_API_URL.rstrip('/')}/recommendations/halal/live"
r = httpx.get(url, params={"limit": 5, "brand": "cucina"}, timeout=20.0)
data["halal_recommendations"] = r.json() if r.status_code == 200 else {"items": []}
except Exception:
data["halal_recommendations"] = {"items": []}
# Legacy veld voor backwards compat — eerste 3 engine results
items = (data.get("halal_recommendations") or {}).get("items") or []
data["top_opportunities"] = [
{
"id": i.get("store_id"),
"name": (i.get("title") or "").split(" · ", 1)[-1],
"chain": i.get("chain"),
"city": i.get("city"),
"halal_opportunity_score": i.get("halal_opportunity_score") or i.get("score"),
"composite_score": i.get("score"),
"reasons": i.get("reasons"),
}
for i in items[:5]
]
async def stream_daily_briefing():
"""Yield SSE step events while building the CEO daily report."""
data: dict[str, Any] = {
@@ -585,6 +665,222 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
return lines[:25]
def _strip_activity_prefix(line: str) -> str:
for prefix in ("", "", "📁 ", "🔧 ", "💾 ", "🖥️ ", "", "🔗 "):
if line.startswith(prefix):
return line[len(prefix) :].strip()
return line.strip()
def _short_activity(line: str, max_len: int = 88) -> str:
s = _strip_activity_prefix(line)
s = re.sub(r"\d{4}-\d{2}-\d{2}$", "", s)
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", "", s).strip()
s = re.sub(r"\s+", " ", s)
if len(s) > max_len:
return s[: max_len - 1].rstrip() + ""
return s
def _fmt_eur(val: float | int | None) -> str:
try:
return f"{float(val or 0):,.0f}"
except (TypeError, ValueError):
return "€—"
def _revenue_line(p: dict[str, Any]) -> str:
mm = p.get("margin_month")
margin = f" · marge/maand {_fmt_eur(mm)}" if mm else ""
obj = p.get("open_objectives") or 0
extra = f" · {obj} open stappen" if obj else ""
return f"{p.get('name')}{margin}{extra}"
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""Gestructureerde live samenvatting — Cucina B2C + Foodlinkk B2B + cockpit."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
pending_n = int(data.get("pending_approvals") or 0)
revenue = data.get("revenue") or {}
rev_stats = revenue.get("stats") or {}
rev_goals = rev_stats.get("goals") or {}
export = data.get("export_intel") or {}
market = data.get("market_summary") or {}
sections: list[dict[str, Any]] = []
# —— Foodlinkk B2B ——
fl_lines: list[str] = [
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
]
if export:
fl_lines.append(
f"Wereldexport: {export.get('distributors', 0):,} distributeurs · "
f"{export.get('entities', 0):,} entiteiten · {export.get('tenders_open', 0)} open tenders · "
f"fase {export.get('phase', '')}"
)
if export.get("crm_linked"):
fl_lines.append(f"Export → CRM gekoppeld: {export.get('crm_linked')} records")
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
fl_projects = sorted(
fl_projects,
key=lambda p: (
1 if "verkopen" in (p.get("name") or "").lower() else 0,
-float(p.get("margin_month") or 0),
),
)
if fl_projects:
fl_lines.append("Revenue projecten: " + "; ".join(_revenue_line(p) for p in fl_projects[:3]))
if rev_goals.get("vision_text"):
vision = str(rev_goals["vision_text"]).strip().split("\n")[0][:90]
fl_lines.append(f"Visie: {vision}")
sections.append({"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines})
# —— Cucina B2C ——
cu_lines: list[str] = [
f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships",
f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items",
]
if rev_stats:
cu_lines.insert(
0,
f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · "
f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen",
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
if cu_projects:
cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4]))
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
if halal_items:
cu_lines.append("🎯 Halal kansen (live engine):")
for rec in halal_items[:4]:
reason = (rec.get("reasons") or [""])[0]
cu_lines.append(
f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')}"
f"score {rec.get('score')} · {reason}"
)
elif opp:
top = opp[0]
cu_lines.append(
f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — "
f"score {round(float(top.get('halal_opportunity_score') or 0))}/100"
)
ms = data.get("milestones_pending") or []
if ms:
cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})")
sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines})
# —— Vandaag in cockpit ——
cockpit_lines: list[str] = []
if pending_n:
cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou")
for row in (data.get("pending_approval_requests") or [])[:3]:
cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}")
for line in activity[:5]:
cockpit_lines.append(_short_activity(line))
if not cockpit_lines:
cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.")
sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "", "lines": cockpit_lines[:6]})
# —— Markt & kennis ——
news_lines: list[str] = []
if market.get("best_performer"):
bp = market["best_performer"]
news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
seen_news: set[str] = set()
for row in (data.get("trending_food") or []):
title = str(row.get("title") or "").strip()[:95]
if not title or title in seen_news:
continue
seen_news.add(title)
news_lines.append(title)
if len(seen_news) >= 2:
break
if data.get("nas_docs"):
news_lines.append(
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
)
if news_lines:
sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]})
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = (row.get("title") or row.get("action_type") or "goedkeuring")[:60]
actions.append(f"@{row.get('agent_key')}: {label}")
for row in (data.get("milestones_pending") or [])[:2]:
actions.append(f"Milestone: {row.get('title')}")
for row in (revenue.get("by_brand") or {}).get("cucina") or []:
if row.get("open_objectives"):
actions.append(f"Cucina follow-up: {row.get('name')}")
break
for rec in halal_items[:2]:
actions.append(f"Cucina outreach: {rec.get('title')}{rec.get('reasons', [''])[0]}")
if export.get("tenders_open"):
actions.append(f"Wereldexport: {export.get('tenders_open')} open tenders bekijken")
if not actions:
actions.extend([
"Retail 360: top-3 halal-gap filialen benaderen",
"Marketing Hub: kant-en-klaar trends checken",
])
long_term = [
"Cucina: schaal supermarkt-listings (AH, Jumbo, PLUS) naar actieve partnerships",
"Foodlinkk: distributeurs in Wereldexport koppelen aan CRM-deals",
"Revenue Cockpit: open stappen en marges wekelijks reviewen",
]
if rev_goals.get("horizon_text"):
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
# Platte summary voor backwards compat (notifications)
summary = f"{data['date']} · Foodlinkk B2B + Cucina B2C · {len(activity)} activiteiten vandaag"
return {
"summary": summary,
"sections": sections,
"actions": actions[:6],
"long_term": long_term[:5],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"source": "live",
}
def format_live_digest_text(digest: dict[str, Any]) -> str:
"""Platte tekst voor Telegram / Herman chat — halal zit in Cucina-sectie."""
lines = ["📋 Herman briefing · live", "", digest.get("summary") or ""]
for section in digest.get("sections") or []:
icon = section.get("icon") or ""
title = section.get("title") or ""
lines.append("")
lines.append(f"{icon} {title}".strip())
for line in section.get("lines") or []:
lines.append(f" · {line}")
actions = digest.get("actions") or []
if actions:
lines.extend(["", "⚡ Korte termijn"])
for action in actions[:6]:
lines.append(f" · {action}")
long_term = digest.get("long_term") or []
if long_term:
lines.extend(["", "🎯 Lange termijn"])
for item in long_term[:5]:
lines.append(f" · {item}")
updated = digest.get("updated_at")
if updated:
ts = str(updated)[:19].replace("T", " ")
n = digest.get("activity_count")
extra = f" · {n} activiteiten" if n is not None else ""
lines.extend(["", f"Bijgewerkt {ts} UTC{extra}"])
return "\n".join(lines)
def build_live_digest_text(data: dict[str, Any] | None = None) -> str:
payload = data if data is not None else collect_briefing_data()
return format_live_digest_text(build_live_digest(payload))
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
@@ -715,39 +1011,19 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
def _fallback_summary(data: dict[str, Any]) -> str:
opp = data.get("top_opportunities") or []
ms = data.get("milestones_pending") or []
lines = [
"## Samenvatting",
f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
]
if opp:
top = opp[0]
lines.append(
f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
)
digest = build_live_digest(data)
lines = ["## Samenvatting", digest["summary"]]
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
"Check Marketing Live Feed voor kant-en-klaar trends",
]
for row in data.get("pending_approval_requests") or []:
if row.get("action_type") == "maintenance_scan":
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
break
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
for a in digest["actions"]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
lines.extend([
"- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
"- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
"- Wekelijks milestones review in Retail 360 sales tab",
])
for a in digest["long_term"]:
lines.append(f"- {a}")
activity = data.get("activity_log") or []
if activity:
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
for entry in activity[:8]:
lines.append(f"- {_strip_activity_prefix(entry)}")
return "\n".join(lines)
@@ -801,4 +1077,6 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
return content, serialize_stats(data)
stats = serialize_stats(data)
stats["live_digest"] = build_live_digest(data)
return content, stats
+38
View File
@@ -28,6 +28,22 @@ IMAGE_KEYWORDS = (
"make image", "maak plaatje", "/genfoto",
)
BRIEFING_KEYWORDS = (
"/briefing", "briefing", "samenvatting", "dagrapport",
"ochtendbriefing", "avondbriefing", "ceo briefing", "live briefing",
)
def _wants_briefing(raw: str) -> bool:
t = (raw or "").strip().lower()
if not t:
return False
if t.startswith("/briefing"):
return True
if len(t) > 120:
return False
return any(k in t for k in BRIEFING_KEYWORDS)
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
@@ -137,6 +153,28 @@ async def chat(
)
return routed
if channel == "telegram" and _wants_briefing(message):
from app.services.briefing import build_live_digest_text, collect_briefing_data
data = collect_briefing_data()
reply = build_live_digest_text(data)
await _log_event(
"herman",
"briefing",
"Live briefing (Telegram)",
reply[:1500],
{"channel": channel, "source": "live_digest"},
channel=channel,
)
return {
"agent": "herman",
"agent_label": "Herman · Briefing",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": "Live cockpit-samenvatting",
"agent_steps": _agent_steps([], "Live digest uit briefing engine"),
}
if webbuilder_agent.wants_website(message):
try:
outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
+475
View File
@@ -0,0 +1,475 @@
window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
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 = '', actions = [], longTerm = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
return { summary: summary, actions: actions, longTerm: longTerm };
}
function typewriter(el, text, speed) {
if (!el) return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
var i = 0;
typewriterTimer = setInterval(function () {
if (i < text.length) { el.textContent += text.charAt(i); i++; }
else clearInterval(typewriterTimer);
}, speed || 8);
}
function countUp(el, end, prefix, suffix) {
if (!el) return;
prefix = prefix || ''; suffix = suffix || '';
var start = 0, dur = 600, t0 = performance.now();
function step(t) {
var p = Math.min(1, (t - t0) / dur);
var v = Math.round(start + (end - start) * p);
el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function chartColors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function renderPipeline(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
if (charts.pipeline) charts.pipeline.destroy();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
options: { responsive: 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 || typeof Chart === 'undefined') return;
var files = stats.nas_files || [], 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();
if (charts.sentiment) charts.sentiment.destroy();
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, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
});
}
function renderWords(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
if (charts.words) charts.words.destroy();
charts.words = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
options: { indexAxis: 'y', responsive: 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 || typeof Chart === 'undefined') return;
var map = {};
(stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
var labels = Object.keys(map), c = chartColors();
if (charts.agents) charts.agents.destroy();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
options: { responsive: 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 eqBars() {
return '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function sparklineSvg(values, trend) {
if (!values || !values.length) return '';
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = max - min || 1;
var pts = values.map(function (v, i) {
var x = (i / (values.length - 1 || 1)) * 100;
var y = 100 - ((v - min) / range) * 80 - 10;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var color = trend === 'down' ? '#fb7185' : '#4ade80';
return '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
}
function stockEqBars(trend) {
var cls = trend === 'down' ? ' hm-eq-down' : '';
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function renderStocksMini(container, stats) {
if (!container) return;
var stocks = (stats.market_stocks || []).slice(0, 4);
var summary = stats.market_summary || {};
if (!stocks.length) {
container.innerHTML = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
'<strong>' + (s.symbol || s.name) + '</strong> ' +
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
}
function renderStocks(container, stats) {
if (!container) return;
var stocks = stats.market_stocks || [];
var summary = stats.market_summary || {};
var meta = document.getElementById('market-updated-at');
if (meta) {
var avg = summary.avg_change_pct;
meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
}
if (!stocks.length) {
container.innerHTML = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
var up = pct >= 0;
var price = s.price != null ? Number(s.price).toFixed(2) : '—';
var cur = s.currency || 'EUR';
return '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
if (!container) return;
var items = stats.regulation_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderTrends(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.market_trends || [];
var c = chartColors();
if (charts.trends) charts.trends.destroy();
if (!rows.length) return;
charts.trends = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
datasets: [{
label: 'Kans %',
data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
borderRadius: 6,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
scales: {
y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
},
},
});
}
function kpiValue(id, stats) {
var summary = stats.market_summary || {};
var best = summary.best_performer || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
case 'wholesalers': return String(stats.wholesalers || 0);
case 'trending_food': return String(trendCount || 0);
case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
case 'pending_approvals': return String(stats.pending_approvals || 0);
case 'deals': return String(stats.deals || 0);
case 'products': return String(stats.products || 0);
case 'suppliers': return String(stats.suppliers || 0);
case 'nas_docs': return String(stats.nas_docs || 0);
case 'promo_campaigns': return String(stats.promo_campaigns || 0);
case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
default: return '—';
}
}
function kpiPct(id, stats) {
var summary = stats.market_summary || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '72%';
case 'clients_active':
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
case 'crm_partnerships': return '45%';
case 'supermarkets': return '88%';
case 'trending_food': return Math.min(95, trendCount * 10) + '%';
case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
case 'pending_approvals': return '30%';
case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
default: return '50%';
}
}
function renderKpis(container, stats, selectedKpis) {
if (!container) return;
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
var order = selectedKpis || window._dashboardKpis ||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
};
});
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
return;
}
container.className = 'hm-neo-kpi-row';
container.innerHTML = items.map(function (it) {
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : inner;
}).join('');
}
function renderRssFeed(container, stats) {
if (!container) return;
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
var count = stats.rss_items || items.length;
var head = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
' · <a href="/marketing">Marketing Hub →</a></div>';
if (!items.length) {
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
var btn = container.querySelector('#btn-rss-refresh-dash');
if (btn) btn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
return;
}
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
var cat = (r.category || 'feed').toUpperCase();
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
'<span class="hm-rss-cat">' + cat + '</span>' +
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
}).join('') + '</div>';
var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
if (refreshBtn) refreshBtn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
}
function renderRetail(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
if (!container) return;
var ms = stats.milestones_pending || [];
if (!ms.length) {
container.innerHTML = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
return;
}
container.innerHTML = ms.map(function (m) {
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
}).join('');
}
function renderExecutiveSummary(container, stats) {
if (!container) return;
var items = [
{ icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
{ icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
{ icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
{ icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
{ icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
{ icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
ms.forEach(function (m) {
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
});
html += '</ul>';
}
var pending = stats.pending_approval_requests || [];
if (pending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
pending.forEach(function (p) {
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8);
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>Genereer dagrapport voor actiepunten</li>';
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
}
function renderLiveSummary(digest, instant) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var summary = digest.summary || '';
if (sumEl) {
if (instant) {
if (typewriterTimer) clearInterval(typewriterTimer);
sumEl.textContent = summary || 'Live samenvatting laden…';
} else {
typewriter(sumEl, summary, 4);
}
}
if (actEl) {
var actions = digest.actions || [];
actEl.innerHTML = actions.length
? actions.map(function (a) { return '<li>' + a + '</li>'; }).join('')
: '<li>Geen urgente acties — check agents & retail</li>';
}
if (longEl) {
var lt = digest.long_term || [];
longEl.innerHTML = lt.length
? lt.map(function (a) { return '<li>' + a + '</li>'; }).join('')
: '<li>Halal kant-en-klaar partnerships schalen</li>';
}
if (meta && digest.updated_at) {
var ts = String(digest.updated_at).substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live samenvatting: ' + ts + ' UTC' + n;
}
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest) {
if (!root || !stats) return;
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, true);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, true);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), stats);
var actEl = document.getElementById('briefing-activity-log');
if (actEl) {
var log = stats.activity_log || [];
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
}
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats, vizMode);
renderText(root, content, createdAt);
}
return {
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
})();
+872
View File
@@ -0,0 +1,872 @@
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 market_stocks, llm_router
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]:
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 _briefing_step(
phase: str,
source: str,
agent: str,
status: str,
message: str,
detail: str | None = None,
) -> dict[str, Any]:
return {
"type": "step",
"phase": phase,
"source": source,
"agent": agent,
"status": status,
"message": message,
"detail": detail,
"at": datetime.now(timezone.utc).isoformat(),
}
def _collect_crm_core(data: dict[str, Any]) -> None:
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'")
def _collect_agent_queue(data: dict[str, Any]) -> None:
try:
data["pending_approval_requests"] = fetch_all(
"""SELECT id, agent_key, action_type, title, query_payload, created_at
FROM agent_action_requests WHERE status = 'pending'
ORDER BY created_at ASC LIMIT 15"""
)
data["pending_approvals"] = len(data["pending_approval_requests"])
except Exception:
data["pending_approval_requests"] = []
try:
data["recent_executed_actions"] = fetch_all(
"""SELECT id, agent_key, action_type, title, result, executed_at, approved_by
FROM agent_action_requests
WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
ORDER BY executed_at DESC LIMIT 12"""
)
except Exception:
data["recent_executed_actions"] = []
def _collect_projects_ops(data: dict[str, Any]) -> None:
try:
data["project_assets_recent"] = fetch_all(
"""SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
FROM project_assets pa
JOIN cockpit_projects cp ON cp.id = pa.project_id
WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
ORDER BY pa.created_at DESC LIMIT 15"""
)
except Exception:
data["project_assets_recent"] = []
try:
data["ops_maintenance_open"] = fetch_all(
"""SELECT severity, title, body, created_at FROM ops_maintenance_notes
WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
)
except Exception:
data["ops_maintenance_open"] = []
try:
data["config_backups_recent"] = fetch_all(
"""SELECT status, message, commit_ref, created_at FROM config_backups
ORDER BY created_at DESC LIMIT 5"""
)
except Exception:
data["config_backups_recent"] = []
try:
data["sysops_activity_24h"] = fetch_all(
"""SELECT action_type, title, body, commit_ref, files_changed, status, created_at
FROM sysops_activity
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["sysops_activity_24h"] = []
try:
data["sysops_events_24h"] = fetch_all(
"""SELECT event_type, title, body, status, created_at, metadata
FROM agent_events
WHERE LOWER(agent_name) = 'sysops'
AND created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 15"""
)
except Exception:
data["sysops_events_24h"] = []
def _collect_pipeline_events(data: dict[str, Any]) -> None:
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["recent_handoffs"] = fetch_all(
"""SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
FROM agent_handoffs
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["recent_handoffs"] = []
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"] = []
def _collect_nas_analytics(data: dict[str, Any]) -> None:
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"] = []
def _collect_retail_intel(data: dict[str, Any]) -> None:
data["supermarkets"] = _safe_count("supermarkets")
data["clients_active"] = _safe_count("clients", "stage = 'active'")
data["clients_total"] = _safe_count("clients")
data["crm_partnerships"] = _safe_count("supermarkets", "partnership_status = 'active'")
data["wholesalers"] = _safe_count("wholesalers")
data["rss_bookmarks"] = _safe_count("rss_bookmarks")
data["promo_campaigns"] = _safe_count("promo_campaigns", "status = 'active'")
try:
data["top_opportunities"] = fetch_all(
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros
JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
)
except Exception:
data["top_opportunities"] = []
try:
data["milestones_pending"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.target_date, sm.value_eur,
s.name AS store_name, s.chain, c.name AS client_name
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
LEFT JOIN clients c ON c.id = sm.client_id
WHERE sm.status IN ('pending', 'in_progress')
ORDER BY sm.target_date ASC NULLS LAST, sm.created_at DESC LIMIT 8"""
)
except Exception:
data["milestones_pending"] = []
try:
data["milestones_recent"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.completed_at, sm.value_eur,
s.name AS store_name, s.chain
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
ORDER BY sm.created_at DESC LIMIT 5"""
)
except Exception:
data["milestones_recent"] = []
def _collect_rss_market(data: dict[str, Any]) -> None:
try:
data["rss_highlights"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE i.title ILIKE ANY (ARRAY['%kant%','%maaltijd%','%supermarkt%','%retail%','%halal%','%jumbo%','%meal%'])
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["rss_highlights"] = []
try:
data["market_trends"] = fetch_all(
"SELECT trend_name, description, opportunity_score FROM market_trends ORDER BY updated_at DESC LIMIT 4"
)
except Exception:
data["market_trends"] = []
try:
quotes = market_stocks.fetch_retail_quotes()
data["market_stocks"] = quotes
data["market_summary"] = market_stocks.market_summary(quotes)
except Exception:
data["market_stocks"] = []
data["market_summary"] = {}
try:
data["regulation_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('regelgeving', 'cbs')
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["regulation_highlights"] = []
try:
data["trending_food"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
)
except Exception:
data["trending_food"] = []
if not data.get("trending_food"):
try:
data["trending_food"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 12"""
)
except Exception:
data["trending_food"] = []
try:
data["food_market_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
if not data.get("food_market_highlights"):
data["food_market_highlights"] = list(data.get("trending_food") or [])[:10]
try:
data["rss_live"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
)
except Exception:
data["rss_live"] = list(data.get("trending_food") or [])
data["rss_items"] = _safe_count("rss_items")
def collect_briefing_data() -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
_collect_crm_core(data)
_collect_agent_queue(data)
_collect_projects_ops(data)
_collect_pipeline_events(data)
_collect_nas_analytics(data)
_collect_retail_intel(data)
_collect_rss_market(data)
data["activity_log"] = _build_activity_log(data)
return data
async def stream_daily_briefing():
"""Yield SSE step events while building the CEO daily report."""
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
_collect_crm_core(data)
yield _briefing_step(
"crm", "PostgreSQL", "crm", "ok",
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
"tables: clients, deals, products, suppliers",
)
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
_collect_agent_queue(data)
pending = data.get("pending_approval_requests") or []
yield _briefing_step(
"agents", "PostgreSQL", "herman", "ok",
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
"table: agent_action_requests",
)
for row in pending[:4]:
agent = row.get("agent_key") or "agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
)
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
_collect_projects_ops(data)
yield _briefing_step(
"projects", "PostgreSQL", "sysops", "ok",
f"{len(data.get('project_assets_recent') or [])} project assets · "
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
"tables: project_assets, sysops_activity, config_backups",
)
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
_collect_pipeline_events(data)
stages = len(data.get("deals_by_stage") or [])
events = data.get("recent_events") or []
yield _briefing_step(
"pipeline", "PostgreSQL", "finance", "ok",
f"{stages} pipeline stages · {len(events)} recente agent-events",
"tables: deals, agent_events",
)
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
_collect_nas_analytics(data)
yield _briefing_step(
"nas", "NAS analytics", "knowledge", "ok",
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
"tables: document_analytics, document_word_counts",
)
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
_collect_retail_intel(data)
opp = data.get("top_opportunities") or []
yield _briefing_step(
"retail", "Retail 360", "retail", "ok",
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} open milestones",
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
)
if opp:
top = opp[0]
yield _briefing_step(
"agent_msg", "agent_mesh", "retail", "agent",
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
)
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
_collect_rss_market(data)
trend_n = len(data.get("trending_food") or [])
yield _briefing_step(
"rss", "RSS feeds", "marketing", "ok",
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
"tables: rss_items, rss_feeds, promo_campaigns",
)
yield _briefing_step(
"agent_msg", "agent_mesh", "marketing", "agent",
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
)
data["activity_log"] = _build_activity_log(data)
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
seen: set[str] = set()
mesh_events = data.get("recent_events") or []
for ev in mesh_events[:10]:
agent = (ev.get("agent_name") or "agent").lower()
if agent in seen:
continue
seen.add(agent)
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
)
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
yield _briefing_step(
"agent_msg", "agent_mesh", "herman", "agent",
f"Herman → {agent_key}: briefing context gedeeld",
)
yield _briefing_step(
"agent_mesh", "Agent mesh", "herman", "ok",
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
)
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
"Herman schrijft executive samenvatting met AI…",
)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
except (asyncio.TimeoutError, Exception) as exc:
ai_part = ""
yield _briefing_step(
"ai", "Ollama", "herman", "warn",
"AI timeout — gebruik template samenvatting",
str(exc)[:120],
)
else:
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
)
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
template = build_template_report(data)
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
stats = serialize_stats(data)
yield _briefing_step("compose", "PostgreSQL", "herman", "ok", "Dagrapport opgeslagen in daily_briefings")
yield {
"type": "done",
"content": content,
"stats": stats,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
def _build_activity_log(data: dict[str, Any]) -> list[str]:
lines: list[str] = []
for row in data.get("pending_approval_requests") or []:
agent = row.get("agent_key") or "agent"
action = row.get("action_type") or "actie"
title = row.get("title") or ""
if action == "maintenance_scan":
lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
elif action == "config_backup":
lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
else:
lines.append(f"{agent} wacht op goedkeuring ({action}): {title}")
for row in data.get("recent_executed_actions") or []:
lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
for row in data.get("project_assets_recent") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
lines.append(
f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
)
for row in data.get("ops_maintenance_open") or []:
lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
for row in data.get("config_backups_recent") or []:
lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
for row in data.get("sysops_activity_24h") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
for row in data.get("sysops_events_24h") or []:
if (row.get("event_type") or "") == "gitea_sync":
continue
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
for row in (data.get("recent_events") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"{row.get('agent_name')}: {row.get('title')} ({ts_s})")
for row in (data.get("recent_handoffs") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"🔗 {row.get('from_agent')}{row.get('to_agent')}: {row.get('handoff_type')} ({ts_s})"
)
return lines[:25]
def _strip_activity_prefix(line: str) -> str:
for prefix in ("", "", "📁 ", "🔧 ", "💾 ", "🖥️ ", "", "🔗 "):
if line.startswith(prefix):
return line[len(prefix) :].strip()
return line.strip()
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""Live samenvatting zonder LLM — volgt cockpit-activiteit (polling / websocket)."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
ms = data.get("milestones_pending") or []
pending_n = int(data.get("pending_approvals") or 0)
executed = data.get("recent_executed_actions") or []
sysops = data.get("sysops_activity_24h") or []
summary_parts = [
(
f"Vandaag ({data['date']}) staat er €{data['pipeline_eur']:,.0f} in je pipeline, "
f"{data.get('crm_partnerships', 0)} actieve supermarkt-partnerships en "
f"{pending_n} open goedkeuring{'en' if pending_n != 1 else ''}."
)
]
if activity:
recent = [_strip_activity_prefix(a) for a in activity[:4]]
summary_parts.append("Recent in het Cockpit: " + "; ".join(recent[:3]) + ".")
elif executed:
row = executed[0]
summary_parts.append(
f"Laatste uitgevoerde actie: {row.get('agent_key')}{row.get('title')}."
)
elif sysops:
row = sysops[0]
summary_parts.append(f"Laatste SysOps: {row.get('title')}.")
elif data.get("recent_events"):
ev = data["recent_events"][0]
summary_parts.append(
f"Laatste agent-event: {ev.get('agent_name')}{ev.get('title')}."
)
else:
summary_parts.append(
"Nog geen nieuwe activiteit vandaag — gebruik Voice, Agents of Export Intel om Herman te voeden."
)
if opp:
top = opp[0]
summary_parts.append(
f"Top retail-kans: {top.get('chain')} · {top.get('name')} in {top.get('city')} "
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
)
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = row.get("title") or row.get("action_type") or "goedkeuring"
actions.append(f"Keur goed: @{row.get('agent_key')}{label}")
for row in executed[:2]:
actions.append(f"Follow-up na {row.get('agent_key')}: {row.get('title')}")
for row in ms[:2]:
actions.append(f"Milestone: {row.get('title')} ({row.get('chain') or 'CRM'})")
if pending_n and not actions:
actions.append(f"Behandel {pending_n} open agent-goedkeuring(en) op het dashboard")
if not actions:
actions.extend([
"Open Retail 360 voor top halal-gap filialen",
"Check Marketing Live Feed voor kant-en-klaar trends",
])
long_term = [
"Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
"Halal kant-en-klaar listing bij regio's met hoogste demografische vraag",
"Wekelijks milestones review in Retail 360",
]
if sysops:
long_term.insert(0, f"IT Ops: {len(sysops)} SysOps-acties in de laatste 24 uur vastleggen en reviewen")
return {
"summary": " ".join(summary_parts),
"actions": actions[:6],
"long_term": long_term[:5],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"source": "live",
}
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']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
f"- **Supermarkten in DB:** {data.get('supermarkets', 0)} · **CRM partnerships:** {data.get('crm_partnerships', 0)}",
f"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}",
"",
]
if data.get("top_opportunities"):
lines.extend(["## Top halal-markt kansen (Retail 360)"])
for row in data["top_opportunities"]:
score = round(float(row.get("halal_opportunity_score") or 0))
lines.append(f"- **{row.get('chain')} · {row.get('name')}** ({row.get('city')}) — score {score}/100")
lines.append("")
if data.get("milestones_pending"):
lines.extend(["## Sales milestones — open"])
for row in data["milestones_pending"]:
td = row.get("target_date")
td_s = td.isoformat()[:10] if hasattr(td, "isoformat") else str(td or "")[:10]
lines.append(f"- [{td_s}] **{row.get('title')}** · {row.get('chain') or ''} {row.get('store_name') or ''} · €{row.get('value_eur') or ''}")
lines.append("")
if data.get("rss_highlights"):
lines.extend(["## Kant-en-klaar & supermarkt nieuws"])
for row in data["rss_highlights"]:
lines.append(f"- [{row.get('feed_name')}] {row.get('title')}")
lines.append("")
if data.get("market_trends"):
lines.extend(["## Markt trends"])
for row in data["market_trends"]:
pct = round(float(row.get("opportunity_score") or 0) * 100)
lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}")
lines.append("")
lines.extend(["## Pipeline per stage"])
for row in data.get("deals_by_stage") or []:
lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
if not data.get("deals_by_stage"):
lines.append("- Geen deals in database.")
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 '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"):
lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')}{row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
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]) -> str:
opp_lines = ""
for row in data.get("top_opportunities") or []:
opp_lines += f"- {row.get('chain')} {row.get('name')} ({row.get('city')}): score {round(float(row.get('halal_opportunity_score') or 0))}\n"
ms_lines = ""
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
)
try:
return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
def _fallback_summary(data: dict[str, Any]) -> str:
digest = build_live_digest(data)
lines = ["## Samenvatting", digest["summary"]]
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
for a in digest["actions"]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
for a in digest["long_term"]:
lines.append(f"- {a}")
activity = data.get("activity_log") or []
if activity:
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
for entry in activity[:8]:
lines.append(f"- {_strip_activity_prefix(entry)}")
return "\n".join(lines)
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 llm_memory (category, subject, content, source, metadata, updated_at)
VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
(
f"Briefing {data['date']}",
content[:8000],
json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
),
)
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, "activity_log": data.get("activity_log", [])}),
),
)
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), timeout=25.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
stats = serialize_stats(data)
stats["live_digest"] = build_live_digest(data)
return content, stats
+12 -4
View File
@@ -252,7 +252,7 @@
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=13"></script>
<script src="/static/js/briefing-charts.js?v=14"></script>
<script>
localStorage.setItem('foodlinkk-persona', 'ceo');
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
@@ -264,6 +264,7 @@ function dashboardBriefing() {
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
rssRegulations: {}, rssBookmarks: [],
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
liveDigest: INITIAL_BRIEFING.live_digest || null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
@@ -274,7 +275,7 @@ function dashboardBriefing() {
DashboardLayout.applyOrder(dash, order);
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
if (this._setDragMode) this._setDragMode(this.editLayout);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
},
scheduleSave(patch) {
if (!this._prefs) return;
@@ -375,9 +376,14 @@ function dashboardBriefing() {
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
}
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
if (this.liveDigest) {
BriefingCharts.renderLiveSummary(this.liveDigest, true);
} else if (this.content) {
BriefingCharts.renderText(dash, this.content, this.createdAt);
}
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
if (this.hermanTab === 'rss') await this.loadRssTab();
},
async refreshRss(toast) {
@@ -454,6 +460,7 @@ function dashboardBriefing() {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) {
this._stats = r.stats;
if (r.live_digest) this.liveDigest = r.live_digest;
try {
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
if (live.items && live.items.length) {
@@ -462,7 +469,7 @@ function dashboardBriefing() {
this.rssTotal = this._stats.rss_items;
}
} catch (e) {}
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
this.renderClientsMini(this._stats);
this.renderActivityLog(this._stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
@@ -523,6 +530,7 @@ function dashboardBriefing() {
this.content = result.content || '';
this.createdAt = result.generated_at || new Date().toISOString();
this._stats = result.stats;
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
this.renderClientsMini(result.stats || {});
this.renderActivityLog((result.stats || {}).activity_log || []);
+15 -4
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
@@ -32,8 +32,14 @@ def _safe_sum(table: str, column: str, where: str = "") -> float:
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}
"""Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
live_stats = serialize_stats(collect_briefing_data())
payload: dict = {
"content": None,
"stats": live_stats,
"live_digest": build_live_digest(live_stats),
"created_at": None,
}
if not briefing:
return payload
@@ -159,7 +165,12 @@ async def dashboard(request: Request):
@router.get("/cto")
async def cto_dashboard(request: Request):
async def cto_redirect():
return RedirectResponse(url="/ops", status_code=302)
@router.get("/cto/dashboard")
async def cto_dashboard_legacy(request: Request):
kpis = {
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"browser_sessions_24h": 0,
+5
View File
@@ -366,3 +366,8 @@
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
.ei-tab-panel-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.ei-link-btn { background: none; border: none; padding: 0; color: #38bdf8; cursor: pointer; font: inherit; text-align: left; }
.ei-link-btn:hover { text-decoration: underline; }
.ei-td-actions { white-space: nowrap; }
.ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; }
+75 -1
View File
@@ -34,11 +34,85 @@
@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } }
.briefing-summary-grid {
display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem;
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;
}
.briefing-card.summary { grid-column: 1 / -1; }
.briefing-horizon-col { display: grid; gap: 1rem; }
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
.live-digest-body {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (max-width: 1100px) { .live-digest-body { grid-template-columns: 1fr; } }
.live-digest-section {
background: rgba(0, 0, 0, 0.22);
border-radius: 10px;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.live-digest-section.brand-foodlinkk { border-left: 3px solid #fbbf24; }
.live-digest-section.brand-cucina { border-left: 3px solid #fb923c; }
.live-digest-section.brand-platform { border-left: 3px solid var(--hm-cyan); }
.live-digest-section.brand-news { border-left: 3px solid #a855f7; }
.live-digest-section h5 {
margin: 0 0 0.45rem;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
}
.live-digest-section.brand-foodlinkk h5 { color: #fbbf24; }
.live-digest-section.brand-cucina h5 { color: #fdba74; }
.live-digest-section.brand-platform h5 { color: var(--hm-cyan); }
.live-digest-section.brand-news h5 { color: #c4b5fd; }
.live-digest-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.live-digest-list li {
font-size: 0.82rem;
line-height: 1.45;
color: #e2e8f0;
padding-left: 0.65rem;
border-left: 2px solid rgba(255, 255, 255, 0.08);
}
.live-digest-list li.is-typing::after {
content: '▋';
display: inline-block;
margin-left: 1px;
color: var(--hm-cyan);
animation: twCursorBlink 0.85s step-end infinite;
font-weight: 400;
vertical-align: baseline;
}
.live-digest-body.is-typing .live-digest-section {
animation: twSectionIn 0.35s ease-out both;
}
@keyframes twCursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes twSectionIn {
from { opacity: 0.4; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.horizon-card ul li.is-typing::after {
content: '▋';
color: #ff9f43;
animation: twCursorBlink 0.85s step-end infinite;
margin-left: 1px;
}
.briefing-card.summary,
.horizon-card {
background: var(--hm-panel); border-radius: 14px; padding: 1rem;
+197 -5
View File
@@ -1,6 +1,8 @@
window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
var typewriterAbort = null;
var lastDigestSig = '';
function destroyAll() {
Object.keys(charts).forEach(function (k) {
@@ -393,17 +395,207 @@ window.BriefingCharts = (function () {
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8);
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl && parsed.summary) {
var lines = parsed.summary.split(/(?<=[.!?])\s+/).filter(Boolean);
typewriterLiveDigest(sumEl, {
sections: [{ brand: 'platform', title: 'Herman rapport', icon: '📝', lines: lines.length ? lines : [parsed.summary] }],
}, { charMs: 10, linePause: 90 });
}
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>Genereer dagrapport voor actiepunten</li>';
if (actEl) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
if (longEl) setTimeout(function () {
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
}, 300);
}
function renderLive(root, stats, vizMode, kpiSelection) {
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function digestSignature(digest) {
if (!digest) return '';
return JSON.stringify({
sections: digest.sections || [],
actions: digest.actions || [],
long_term: digest.long_term || [],
});
}
function fillDigestInstant(container, digest) {
var sections = (digest && digest.sections) || [];
if (!sections.length) {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5>' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
}
function typewriterLiveDigest(container, digest, opts) {
if (!container) return;
opts = opts || {};
var charMs = opts.charMs || 14;
var linePause = opts.linePause || 120;
var sectionPause = opts.sectionPause || 200;
if (typewriterAbort) { typewriterAbort(); typewriterAbort = null; }
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
var sections = (digest && digest.sections) || [];
if (!sections.length) {
var summary = (digest && digest.summary) || '';
if (summary) {
typewriter(container, summary, charMs);
} else {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
}
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function () { return '<li class="tw-line"></li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5 class="tw-heading">' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
var queue = [];
container.querySelectorAll('.live-digest-section').forEach(function (secEl, si) {
var lines = (sections[si] && sections[si].lines) || [];
var lis = secEl.querySelectorAll('li.tw-line');
for (var i = 0; i < lis.length; i++) {
queue.push({ el: lis[i], text: lines[i] || '', section: si });
}
});
var cancelled = false;
typewriterAbort = function () { cancelled = true; };
container.classList.add('is-typing');
function tick(qi, ci, prevSection) {
if (cancelled) return;
if (qi >= queue.length) {
container.classList.remove('is-typing');
container.querySelectorAll('.is-typing').forEach(function (n) { n.classList.remove('is-typing'); });
return;
}
var item = queue[qi];
var el = item.el;
var text = item.text;
if (prevSection !== item.section && ci === 0) {
setTimeout(function () { if (!cancelled) tick(qi, ci, item.section); }, sectionPause);
return;
}
el.classList.add('is-typing');
container.querySelectorAll('li.is-typing').forEach(function (n) { if (n !== el) n.classList.remove('is-typing'); });
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
setTimeout(function () { tick(qi, ci + 1, item.section); }, charMs);
} else {
el.classList.remove('is-typing');
setTimeout(function () { tick(qi + 1, 0, item.section); }, linePause);
}
}
tick(0, 0, -1);
}
function typewriterListItems(listEl, items, opts) {
if (!listEl || !items || !items.length) return;
opts = opts || {};
var charMs = opts.charMs || 10;
listEl.innerHTML = items.map(function () { return '<li class="tw-line"></li>'; }).join('');
var lis = listEl.querySelectorAll('li.tw-line');
var qi = 0, ci = 0, cancelled = false;
function next() {
if (cancelled || qi >= items.length) return;
var el = lis[qi];
var text = items[qi];
el.classList.add('is-typing');
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
ci++;
setTimeout(next, charMs);
} else {
el.classList.remove('is-typing');
qi++; ci = 0;
setTimeout(next, 90);
}
}
next();
return function () { cancelled = true; };
}
function fillListInstant(listEl, items) {
if (!listEl) return;
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
}
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var sig = digestSignature(digest);
var unchanged = pollRefresh && sig === lastDigestSig;
if (!unchanged) lastDigestSig = sig;
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
}
if (unchanged) {
if (actEl) fillListInstant(actEl, digest.actions || []);
if (longEl) fillListInstant(longEl, digest.long_term || []);
return;
}
var isUpdate = !!lastDigestSig && !pollRefresh;
var twOpts = { charMs: isUpdate ? 8 : 12, linePause: isUpdate ? 70 : 100, sectionPause: isUpdate ? 120 : 180 };
if (sumEl) {
if (pollRefresh) fillDigestInstant(sumEl, digest);
else typewriterLiveDigest(sumEl, digest, twOpts);
}
var actions = digest.actions || [];
if (actEl) {
if (pollRefresh) fillListInstant(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail']);
else typewriterListItems(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail'], { charMs: isUpdate ? 6 : 8 });
}
var lt = digest.long_term || [];
if (longEl) {
if (pollRefresh) fillListInstant(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen']);
else setTimeout(function () {
typewriterListItems(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen'], { charMs: isUpdate ? 6 : 8 });
}, isUpdate ? 200 : 400);
}
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
if (!root || !stats) return;
opts = opts || {};
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
@@ -432,7 +624,7 @@ window.BriefingCharts = (function () {
}
return {
render: render, renderLive: renderLive, renderText: renderText,
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
+74 -4
View File
@@ -12,6 +12,8 @@ function exportIntelApp() {
region: '',
entities: [],
contacts: [],
contactsHasMore: false,
contactsLoadingMore: false,
tenders: [],
catererPresence: [],
catererBrands: [],
@@ -139,7 +141,7 @@ function exportIntelApp() {
},
showEntityTypeFilter() {
return ['distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
return ['distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showMapTypeFilter() {
@@ -183,6 +185,14 @@ function exportIntelApp() {
return this.tab === 'contacts';
},
showCrmFilter() {
return this.tab === 'map' || this.tab === 'contacts';
},
showSelectionBar() {
return this.tab === 'map' || this.tab === 'contacts';
},
entityTypeOptions() {
if (this.tab === 'distributors') {
return [
@@ -205,6 +215,18 @@ function exportIntelApp() {
if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
}
if (this.tab === 'contacts') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'importer', l: 'Importeur' },
{ v: 'logistics', l: 'Logistiek' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
{ v: 'foodservice', l: 'Foodservice' },
];
}
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
@@ -214,7 +236,8 @@ function exportIntelApp() {
];
},
async loadTabData() {
async loadTabData(opts) {
opts = opts || {};
var base = '/api/export-intel';
var fq = this.filterQs();
if (this.tab === 'map') {
@@ -245,9 +268,17 @@ function exportIntelApp() {
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
} else if (this.tab === 'contacts') {
var r4 = await fetch(base + '/contacts?limit=500' + fq).then((x) => x.json());
this.contacts = r4.items || [];
if (!opts.append) {
this.contacts = [];
this.contactsHasMore = false;
}
var offset = opts.append ? (this.contacts || []).length : 0;
var r4 = await fetch(base + '/contacts?limit=200&offset=' + offset + fq).then((x) => x.json());
var items = r4.items || [];
if (opts.append) this.contacts = (this.contacts || []).concat(items);
else this.contacts = items;
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
this.contactsHasMore = this.contacts.length < this.resultCount;
} else if (this.tab === 'tenders') {
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
var items = tenders.items || [];
@@ -468,10 +499,26 @@ function exportIntelApp() {
var u = '/api/export-intel/contacts/export.csv?';
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
if (this.filterHasEmail === 'no') parts.push('has_email=false');
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
return u + parts.join('&');
},
async loadMoreContacts() {
if (!this.contactsHasMore || this.contactsLoadingMore) return;
this.contactsLoadingMore = true;
try {
await this.loadTabData({ append: true });
} finally {
this.contactsLoadingMore = false;
}
},
typeLabel(t) {
return (t || '').replace(/_/g, ' ');
},
@@ -494,6 +541,25 @@ function exportIntelApp() {
},
toggleSelectAllVisible() {
if (this.tab === 'contacts') {
var entityIds = [];
(this.contacts || []).forEach(function (c) {
if (c.entity_id && entityIds.indexOf(c.entity_id) < 0) entityIds.push(c.entity_id);
});
if (!entityIds.length) return;
var allSelected = entityIds.every((id) => this.isSelected(id));
if (allSelected) {
entityIds.forEach((id) => {
var i = this.selectedIds.indexOf(id);
if (i >= 0) this.selectedIds.splice(i, 1);
});
} else {
entityIds.forEach((id) => {
if (!this.isSelected(id)) this.selectedIds.push(id);
});
}
return;
}
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id));
@@ -590,6 +656,10 @@ function exportIntelApp() {
},
onFilterCollectionChange() {
if (this.tab === 'contacts') {
this.loadTabData();
return;
}
this.applyMapFilters();
},
};
+23 -1
View File
@@ -9,19 +9,41 @@ window.CockpitLive = (function () {
return function () { clearInterval(id); };
}
function eventFingerprint(data) {
var ev = (data && data.events) || [];
if (!ev.length) return '';
return ev.slice(0, 5).map(function (e) {
return String(e.created_at || '') + '#' + String(e.agent_name || '') + '#' + String(e.title || e.event_type || '');
}).join('|');
}
function connectFeed(onMessage) {
if (typeof WebSocket === 'undefined') return function () {};
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var lastFp = '';
var booted = false;
function connect() {
try {
ws = new WebSocket(proto + '//' + location.host + '/ws/feed');
ws.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (onMessage) onMessage(data);
var fp = eventFingerprint(data);
if (!booted) {
booted = true;
lastFp = fp;
if (onMessage) onMessage(data, { reason: 'connect' });
return;
}
if (fp && fp !== lastFp) {
lastFp = fp;
if (onMessage) onMessage(data, { reason: 'events' });
}
} catch (e) {}
};
ws.onclose = function () {
booted = false;
lastFp = '';
reconnectTimer = setTimeout(connect, 5000);
};
} catch (e) {
+7
View File
@@ -3,6 +3,7 @@ function revenueCockpit() {
loading: false,
saving: false,
savedAt: '',
_pollStop: null,
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
@@ -46,6 +47,12 @@ function revenueCockpit() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
if (window.CockpitLive) {
this._pollStop = CockpitLive.startPolling(async () => {
await this.loadLive();
await this.loadDbQuiet();
}, 45000);
}
},
projectKey(p) {
+1 -1
View File
@@ -189,7 +189,7 @@
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script src="/static/js/live-pulse.js?v=3"></script>
<script>
window.FoodlinkkBrand = (function () {
var FOODLINKK_PREFIXES = ['/export-intel', '/foodlinkk', '/clients', '/deals', '/products', '/suppliers'];
+41 -10
View File
@@ -2,7 +2,7 @@
{% 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=17" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=21" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
@@ -93,8 +93,8 @@
<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>
<h4>Samenvatting · live</h4>
<div id="briefing-summary-text" class="live-digest-body"></div>
</div>
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
<h4>📋 Herman activiteitenlog</h4>
@@ -252,7 +252,7 @@
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=13"></script>
<script src="/static/js/briefing-charts.js?v=19"></script>
<script>
localStorage.setItem('foodlinkk-persona', 'ceo');
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
@@ -264,7 +264,9 @@ function dashboardBriefing() {
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
rssRegulations: {}, rssBookmarks: [],
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
liveDigest: INITIAL_BRIEFING.live_digest || null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_liveRefreshing: false, _pollTick: 0,
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
this._prefs = prefs;
@@ -274,7 +276,7 @@ function dashboardBriefing() {
DashboardLayout.applyOrder(dash, order);
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
if (this._setDragMode) this._setDragMode(this.editLayout);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
},
scheduleSave(patch) {
if (!this._prefs) return;
@@ -375,9 +377,14 @@ function dashboardBriefing() {
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
}
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
if (this.liveDigest) {
BriefingCharts.renderLiveSummary(this.liveDigest, false);
} else if (this.content) {
BriefingCharts.renderText(dash, this.content, this.createdAt);
}
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 20000);
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
if (this.hermanTab === 'rss') await this.loadRssTab();
},
async refreshRss(toast) {
@@ -448,12 +455,34 @@ function dashboardBriefing() {
'<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>';
},
updateLiveMeta(at, digest) {
const tsRaw = at || (digest && digest.updated_at) || '';
const ts = String(tsRaw).substring(0, 19).replace('T', ' ');
const meta = document.getElementById('briefing-meta');
if (meta && ts) {
const n = digest && digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
}
const badge = document.querySelector('.herman-hero .live-badge');
if (badge && tsRaw) {
try {
badge.textContent = 'Live · ' + new Date(tsRaw).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
} catch (e) {}
}
},
async refreshLive(toast) {
if (this._liveRefreshing) return;
this._liveRefreshing = true;
const isPoll = toast === false;
if (isPoll) this._pollTick = (this._pollTick || 0) + 1;
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
const light = isPoll && (this._pollTick % 6 !== 0);
const url = light ? '/api/herman/briefing/stats?light=1' : '/api/herman/briefing/stats';
const r = await fetch(url).then(x => x.json());
if (r.stats) {
this._stats = r.stats;
if (r.live_digest) this.liveDigest = r.live_digest;
try {
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
if (live.items && live.items.length) {
@@ -462,13 +491,14 @@ function dashboardBriefing() {
this.rssTotal = this._stats.rss_items;
}
} catch (e) {}
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest, { pollRefresh: isPoll });
this.renderClientsMini(this._stats);
this.renderActivityLog(this._stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
this.updateLiveMeta(r.at, this.liveDigest);
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
finally { this._liveRefreshing = false; }
},
pushLiveEntry(entry) {
this.liveLog.push(entry);
@@ -523,6 +553,7 @@ function dashboardBriefing() {
this.content = result.content || '';
this.createdAt = result.generated_at || new Date().toISOString();
this._stats = result.stats;
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
this.renderClientsMini(result.stats || {});
this.renderActivityLog((result.stats || {}).activity_log || []);
+51 -15
View File
@@ -4,7 +4,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=11" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=12" />
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
@@ -31,10 +31,10 @@
<p class="ei-sync-msg" x-show="syncMsg" x-text="syncMsg"></p>
<div class="ei-selection-bar" x-show="tab === 'map'" x-cloak>
<div class="ei-selection-bar" x-show="showSelectionBar()" x-cloak>
<div class="ei-selection-left">
<span class="ei-selection-count" x-show="selectedIds.length" x-text="selectedIds.length + ' geselecteerd'"></span>
<span class="ei-selection-hint" x-show="!selectedIds.length">Selecteer rijen met ☑ voor bulk-acties</span>
<span class="ei-selection-hint" x-show="!selectedIds.length" x-text="tab === 'contacts' ? 'Selecteer contacten met ☑ — organisatie gaat naar CRM' : 'Selecteer rijen met ☑ voor bulk-acties'"></span>
</div>
<div class="ei-selection-actions">
<button type="button" class="btn btn-sm" @click="toggleSelectAllVisible()" title="Alles op lijst">☑ Alles</button>
@@ -137,8 +137,8 @@
<strong x-text="mapHalalMin"></strong>
</label>
</div>
<div class="ei-filter-group ei-filter-group-collection" x-show="tab === 'map'" x-cloak>
<label class="ei-filter-check">
<div class="ei-filter-group ei-filter-group-collection" x-show="showCrmFilter()" x-cloak>
<label class="ei-filter-check" x-show="tab === 'map'">
<input type="checkbox" x-model="filterFavoritesOnly" @change="onFilterCollectionChange()" />
<span>★ Alleen favorieten</span>
</label>
@@ -478,31 +478,67 @@
<section x-show="tab==='contacts'" x-cloak>
<div class="ei-tab-panel">
<div class="ei-tab-panel-head">
<div><h2>Contactregistry</h2><p>E-mail · telefoon · outreach-ready</p></div>
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
<div><h2>Contactregistry</h2><p>E-mail · telefoon · outreach-ready · direct naar CRM</p></div>
<div class="ei-tab-panel-actions">
<button type="button" class="btn btn-sm btn-pulse-green" :disabled="!selectedIds.length || crmPushBusy" @click="openCrmModal()"> Geselecteerde naar CRM</button>
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
</div>
</div>
<div class="ei-tab-panel-body">
<div class="ei-panel" style="border:none;background:transparent;padding:0">
<div class="ei-table-wrap">
<table class="ei-table">
<thead><tr><th>Organisatie</th><th>Type</th><th>Land</th><th>Rol</th><th>Naam</th><th>E-mail</th><th>Telefoon</th></tr></thead>
<thead>
<tr>
<th class="ei-th-check"><input type="checkbox" @change="toggleSelectAllVisible()" title="Alles selecteren" /></th>
<th>Organisatie</th>
<th>Stad</th>
<th>Type</th>
<th>Land</th>
<th>Rol</th>
<th>Naam</th>
<th>E-mail</th>
<th>Telefoon</th>
<th>CRM</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="c in contacts" :key="c.id">
<tr>
<td x-text="c.entity_name"></td>
<td x-text="typeLabel(c.entity_type)"></td>
<tr :class="{ 'ei-row-selected': isSelected(c.entity_id), 'ei-row-crm': c.client_id }">
<td class="ei-td-check" @click.stop>
<input type="checkbox" :checked="isSelected(c.entity_id)" @change="toggleSelect(c.entity_id, $event)" />
</td>
<td>
<button type="button" class="ei-link-btn" @click="openEntity(c.entity_id)" x-text="c.entity_name"></button>
</td>
<td x-text="c.entity_city || '—'"></td>
<td><span class="ei-type-pill ei-type-pill-sm" :class="'ei-type-'+c.entity_type" x-text="typeLabel(c.entity_type)"></span></td>
<td x-text="c.country_iso2"></td>
<td x-text="c.role"></td>
<td x-text="c.role || '—'"></td>
<td x-text="c.name||'—'"></td>
<td x-text="c.email||'—'"></td>
<td x-text="c.phone||c.mobile||'—'"></td>
<td>
<span class="ei-crm-badge" x-show="c.client_id" :title="c.crm_client_name || 'In CRM'">CRM</span>
<span class="muted" x-show="!c.client_id"></span>
</td>
<td class="ei-td-actions" @click.stop>
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!c.client_id" @click="pushEntityToCrm(c.entity_id)" :disabled="crmPushBusy"> CRM</button>
<a class="btn btn-sm" x-show="c.client_id" :href="'/clients'" title="Bekijk in CRM">Open</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="ei-empty" x-show="!contacts.length">
<p>Contacten worden aangevuld in fase B (Places, websites, tenders).</p>
<div class="ei-load-more" x-show="contactsHasMore">
<button type="button" class="btn btn-sm" @click="loadMoreContacts()" :disabled="contactsLoadingMore || busy">
<span x-text="contactsLoadingMore ? 'Laden…' : ('Meer laden (' + contacts.length + ' / ' + resultCount + ')')"></span>
</button>
</div>
<div class="ei-empty" x-show="!contacts.length && !busy">
<p>Geen contacten voor deze filters — pas land, type of zoekterm aan.</p>
<button class="btn btn-sm btn-pulse-green" @click="syncKind('contacts')">Sync contacten</button>
</div>
</div>
@@ -760,5 +796,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/export-intel.js?v=10"></script>
<script src="/static/js/export-intel.js?v=11"></script>
{% endblock %}
+1 -1
View File
@@ -258,5 +258,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=5"></script>
<script src="/static/js/revenue-cockpit.js?v=6"></script>
{% endblock %}