Improve CEO cockpit clarity and Revenue visuals, with Hermes briefing.
Tighten overview KPIs/pipeline height, add selectable Revenue views and a resizable sidebar, and keep supporting Hermes/Wereldexport updates.
This commit is contained in:
@@ -16,6 +16,10 @@ class Settings:
|
||||
OPENROUTER_BASE_URL: str = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
||||
# orchestrator | router | auto (auto = orchestrator, fallback naar standaard LLM provider)
|
||||
HERMAN_LLM_BACKEND: str = os.getenv("HERMAN_LLM_BACKEND", "auto")
|
||||
# Hermes Agent (OpenAI-compatible) — Herman briefing backend
|
||||
HERMES_API_BASE: str = os.getenv("HERMES_API_BASE", "http://10.4.7.10:8642/v1")
|
||||
HERMES_API_KEY: str = os.getenv("HERMES_API_KEY", "")
|
||||
HERMES_API_MODEL: str = os.getenv("HERMES_API_MODEL", "hermes-agent")
|
||||
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
|
||||
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
||||
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
||||
|
||||
+228
-245
@@ -467,126 +467,77 @@ async def stream_daily_briefing():
|
||||
|
||||
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…")
|
||||
yield _briefing_step("crm", "CRM", "crm", "running", "Pipeline & klanten ophalen…")
|
||||
_collect_crm_core(data)
|
||||
yield _briefing_step(
|
||||
"crm", "PostgreSQL", "crm", "ok",
|
||||
"crm", "CRM", "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…")
|
||||
yield _briefing_step("agents", "Herman", "herman", "running", "Open beslissingen filteren…")
|
||||
_collect_agent_queue(data)
|
||||
data["pending_approval_requests"] = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
data["pending_approvals"] = len(data["pending_approval_requests"])
|
||||
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')}",
|
||||
"agents", "Herman", "herman", "ok",
|
||||
f"{len(pending)} relevante beslissingen",
|
||||
)
|
||||
|
||||
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
|
||||
yield _briefing_step("projects", "Operations", "product", "running", "Milestones & projectstatus…")
|
||||
_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",
|
||||
"projects", "Operations", "product", "ok",
|
||||
f"{len(data.get('milestones_pending') or [])} open milestones",
|
||||
)
|
||||
|
||||
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
|
||||
yield _briefing_step("pipeline", "Finance", "finance", "running", "Pipeline stages laden…")
|
||||
_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",
|
||||
"pipeline", "Finance", "finance", "ok",
|
||||
f"{stages} pipeline stages",
|
||||
)
|
||||
|
||||
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
|
||||
# NAS collected for internal stats but not streamed as CEO noise
|
||||
_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…")
|
||||
yield _briefing_step("retail", "Retail", "retail", "running", "Supermarkten & partnerships…")
|
||||
_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')})",
|
||||
"retail", "Retail", "retail", "ok",
|
||||
f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · "
|
||||
f"{len(data.get('milestones_pending') or [])} milestones",
|
||||
)
|
||||
|
||||
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
|
||||
yield _briefing_step("rss", "Markt", "marketing", "running", "Food & retail nieuws selecteren…")
|
||||
_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",
|
||||
"rss", "Markt", "marketing", "ok",
|
||||
f"{trend_n} relevante food-headlines",
|
||||
)
|
||||
|
||||
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)
|
||||
model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-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…",
|
||||
"ai", f"Herman ({model_label})", "herman", "running",
|
||||
"Herman schrijft CEO-briefing…",
|
||||
)
|
||||
try:
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
|
||||
except (asyncio.TimeoutError, Exception) as exc:
|
||||
ai_part = ""
|
||||
yield _briefing_step(
|
||||
"ai", "Ollama", "herman", "warn",
|
||||
"AI timeout — gebruik template samenvatting",
|
||||
"ai", "Herman", "herman", "warn",
|
||||
"AI timeout — 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)",
|
||||
"ai", f"Herman ({model_label})", "herman", "ok",
|
||||
f"Briefing klaar ({len(ai_part or '')} tekens)",
|
||||
)
|
||||
|
||||
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
|
||||
@@ -607,6 +558,34 @@ async def stream_daily_briefing():
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _fresh_approvals(rows: list | None, *, max_age_days: int = 14) -> list:
|
||||
"""Drop stale agent approvals so SysOps noise does not dominate the CEO digest."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
out = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
||||
for row in rows or []:
|
||||
ts = row.get("created_at") or row.get("requested_at") or row.get("updated_at")
|
||||
if ts is None:
|
||||
continue
|
||||
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
if ts < cutoff:
|
||||
continue
|
||||
except TypeError:
|
||||
continue
|
||||
agent = (row.get("agent_key") or "").lower()
|
||||
action = (row.get("action_type") or "").lower()
|
||||
# Keep business agents; skip routine SysOps unless critical keyword
|
||||
if agent in ("sysops", "ops", "it") or action in ("maintenance_scan", "config_backup", "gitea_sync"):
|
||||
title = (row.get("title") or "").lower()
|
||||
if not any(k in title for k in ("critical", "kritiek", "down", "security", "breach")):
|
||||
continue
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _build_activity_log(data: dict[str, Any]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
@@ -698,151 +677,161 @@ def _revenue_line(p: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
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)
|
||||
"""CEO digest: B2B + B2C + focus + trends + nieuws — geen SysOps."""
|
||||
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 {}
|
||||
trends = data.get("market_trends") or []
|
||||
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
|
||||
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
# —— Foodlinkk B2B ——
|
||||
# —— Foodlinkk B2B (compact) ——
|
||||
fl_lines: list[str] = [
|
||||
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
|
||||
f"Pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
|
||||
]
|
||||
if export:
|
||||
if export.get("tenders_open") or export.get("distributors"):
|
||||
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', '—')}"
|
||||
f"Export: {export.get('distributors', 0):,} distributeurs · {export.get('tenders_open', 0)} open tenders"
|
||||
)
|
||||
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),
|
||||
),
|
||||
[p for p in fl_projects if p.get("margin_month")],
|
||||
key=lambda p: -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})
|
||||
n_rev = 0
|
||||
for p in fl_projects:
|
||||
name_l = (p.get("name") or "").lower()
|
||||
if any(x in name_l for x in ("verkopen", "2030", "total earnings", "total expense", "loonkosten", "boekhouder", "linknbit")):
|
||||
continue
|
||||
fl_lines.append(_revenue_line(p))
|
||||
n_rev += 1
|
||||
if n_rev >= 2:
|
||||
break
|
||||
if export.get("crm_linked"):
|
||||
fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld")
|
||||
|
||||
# —— 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",
|
||||
]
|
||||
# —— Cucina B2C (compact) ——
|
||||
cu_lines: list[str] = []
|
||||
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_lines.append(
|
||||
f"Marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
|
||||
f"{rev_stats.get('open_objectives', 0)} open stappen"
|
||||
)
|
||||
cu_lines.append(
|
||||
f"Retail: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} partnerships"
|
||||
)
|
||||
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:
|
||||
deal_bits = []
|
||||
for p in cu_projects[:4]:
|
||||
name = (p.get("name") or "").strip()
|
||||
if not name or "marketing" in name.lower() or "boekhouder" in name.lower():
|
||||
continue
|
||||
seen_news.add(title)
|
||||
news_lines.append(title)
|
||||
if len(seen_news) >= 2:
|
||||
mm = p.get("margin_month")
|
||||
bit = name
|
||||
if mm:
|
||||
bit += f" {_fmt_eur(mm)}/m"
|
||||
if p.get("open_objectives"):
|
||||
bit += f" · {p.get('open_objectives')} stappen"
|
||||
deal_bits.append(bit)
|
||||
if len(deal_bits) >= 3:
|
||||
break
|
||||
if data.get("nas_docs"):
|
||||
news_lines.append(
|
||||
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
|
||||
if deal_bits:
|
||||
cu_lines.append("Deals: " + " · ".join(deal_bits))
|
||||
if data.get("promo_campaigns"):
|
||||
cu_lines.append(f"Marketing: {data.get('promo_campaigns')} actieve promo's")
|
||||
if halal_items:
|
||||
rec = halal_items[0]
|
||||
cu_lines.append(
|
||||
f"Halal-kans: {rec.get('chain')} · {rec.get('city')} (score {int(float(rec.get('halal_opportunity_score') or rec.get('score') or 0))})"
|
||||
)
|
||||
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}")
|
||||
sections = [
|
||||
{"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines},
|
||||
{"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines},
|
||||
]
|
||||
|
||||
# —— Focus vandaag (business only, no sysops) ——
|
||||
focus_lines: list[str] = []
|
||||
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')}")
|
||||
title = (row.get("title") or "").strip()
|
||||
if title:
|
||||
focus_lines.append(title[:90])
|
||||
for p in cu_projects:
|
||||
if p.get("open_objectives") and (p.get("name") or "").lower() in ("ah", "jumbo", "picnic", "plus"):
|
||||
focus_lines.append(f"Cucina follow-up: {p.get('name')} ({p.get('open_objectives')} open stappen)")
|
||||
break
|
||||
for rec in halal_items[:2]:
|
||||
actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
|
||||
reason = ((rec.get("reasons") or [""])[0] or "")[:60]
|
||||
focus_lines.append(f"Outreach {rec.get('chain')} · {rec.get('city')}" + (f" — {reason}" if reason else ""))
|
||||
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",
|
||||
])
|
||||
focus_lines.append(f"Bekijk {export.get('tenders_open')} open export-tenders")
|
||||
# Skip sysops / infra approvals entirely
|
||||
if not focus_lines:
|
||||
focus_lines.append("Pipeline en Cucina retail-deals nalopen")
|
||||
sections.append({"brand": "platform", "title": "Focus vandaag", "icon": "🎯", "lines": focus_lines[:4]})
|
||||
|
||||
# —— Trends ——
|
||||
trend_lines: list[str] = []
|
||||
for tr in trends[:4]:
|
||||
name = (tr.get("trend_name") or tr.get("name") or "").strip()
|
||||
score = tr.get("opportunity_score")
|
||||
if not name:
|
||||
continue
|
||||
if score is not None:
|
||||
pct = int(float(score) * 100) if float(score) <= 1 else int(float(score))
|
||||
trend_lines.append(f"{name} ↑{pct}%")
|
||||
else:
|
||||
trend_lines.append(name)
|
||||
if market.get("best_performer"):
|
||||
bp = market["best_performer"]
|
||||
trend_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
|
||||
if not trend_lines:
|
||||
trend_lines.append("Geen trenddata — check Retail 360")
|
||||
|
||||
# —— Nieuws (max 5, food only) ——
|
||||
news: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
skip = ("mediamarkt", "ceconomy", "elektro", "airco", "gaming", "smartphone", "sysops", "gitea", "backup")
|
||||
for row in list(data.get("trending_food") or []) + list(data.get("rss_highlights") or []) + list(data.get("rss_live") or []):
|
||||
title = str(row.get("title") or "").strip()
|
||||
low = title.lower()
|
||||
if not title or title in seen:
|
||||
continue
|
||||
if any(x in low for x in skip):
|
||||
continue
|
||||
seen.add(title)
|
||||
news.append({
|
||||
"title": title[:110],
|
||||
"link": row.get("link") or "#",
|
||||
"source": row.get("feed_name") or "RSS",
|
||||
})
|
||||
if len(news) >= 5:
|
||||
break
|
||||
|
||||
actions = focus_lines[:4]
|
||||
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",
|
||||
"Cucina: schaal AH/Jumbo/PLUS naar actieve partnerships",
|
||||
"Foodlinkk: export-distributeurs koppelen aan CRM-deals",
|
||||
"Revenue: open stappen 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"
|
||||
summary = f"{data.get('date')} · Foodlinkk B2B + Cucina B2C"
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"sections": sections,
|
||||
"actions": actions[:6],
|
||||
"long_term": long_term[:5],
|
||||
"b2b": fl_lines,
|
||||
"b2c": cu_lines,
|
||||
"focus": focus_lines[:4],
|
||||
"trends": trend_lines[:5],
|
||||
"news": news,
|
||||
"actions": actions,
|
||||
"long_term": long_term[:4],
|
||||
"updated_at": data.get("generated_at"),
|
||||
"activity_count": len(activity),
|
||||
"activity_count": len(data.get("activity_log") or []),
|
||||
"source": "live",
|
||||
}
|
||||
|
||||
@@ -885,7 +874,7 @@ 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}*",
|
||||
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Herman via OpenCode*",
|
||||
"",
|
||||
"## KPI's",
|
||||
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
|
||||
@@ -935,41 +924,11 @@ def build_template_report(data: dict[str, Any]) -> str:
|
||||
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')}")
|
||||
fresh_appr = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
if fresh_appr:
|
||||
lines.extend(["", "## Open beslissingen"])
|
||||
for row in fresh_appr[:8]:
|
||||
lines.append(f"- **{row.get('agent_key')}**: {row.get('title')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -983,31 +942,60 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
|
||||
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"
|
||||
news_lines = ""
|
||||
for row in (data.get("trending_food") or [])[:5]:
|
||||
title = str(row.get("title") or "").strip()
|
||||
if title:
|
||||
news_lines += f"- {title}\n"
|
||||
|
||||
fresh = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
decision_lines = ""
|
||||
for row in fresh[:5]:
|
||||
decision_lines += f"- @{row.get('agent_key')}: {row.get('title') or row.get('action_type')}\n"
|
||||
|
||||
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"
|
||||
"Schrijf een strak CEO-memo in het Nederlands (markdown) voor Aïssa (Foodlinkk / Cucina, "
|
||||
"halal kant-en-klaar). Geen IT, geen SysOps, geen NAS, geen agent-mesh.\n\n"
|
||||
"## Stand van zaken\n(4-6 zinnen: business context vandaag)\n\n"
|
||||
"## Pipeline & revenue\n(bullets met cijfers en wat dat betekent)\n\n"
|
||||
"## Retail & partnerships\n(supermarkten, milestones, halal-kansen)\n\n"
|
||||
"## Markt\n(max 3 relevante food/retail signalen)\n\n"
|
||||
"## Beslissingen vandaag\n(3-5 concrete CEO-acties)\n\n"
|
||||
f"Data ({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"- {data.get('supermarkets',0)} filialen, {data.get('crm_partnerships',0)} partnerships\n"
|
||||
f"Top kansen:\n{opp_lines or '- geen data'}\n"
|
||||
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
|
||||
f"Milestones:\n{ms_lines or '- geen'}\n"
|
||||
f"Markt:\n{news_lines or '- geen'}\n"
|
||||
f"Open beslissingen:\n{decision_lines or '- geen'}\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."
|
||||
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf als een executive briefing: "
|
||||
"helder, zakelijk, actionable. Focus op omzet, retail, partnerships en keuzes. "
|
||||
"Noem geen servers, backups, Gitea, NAS of SysOps. "
|
||||
"Antwoord UITSLUITEND met de markdown-memo. Geen voorwoord, geen bestandsnamen, "
|
||||
"geen vragen aan de gebruiker, geen tools of Telegram-aanbod."
|
||||
)
|
||||
try:
|
||||
return await llm_router.generate(prompt, system=system, timeout=120.0)
|
||||
raw = await llm_router.generate(prompt, system=system, timeout=120.0)
|
||||
except Exception:
|
||||
return ""
|
||||
text = (raw or "").strip()
|
||||
for marker in ("## Stand van zaken", "## Samenvatting", "# "):
|
||||
idx = text.find(marker)
|
||||
if idx > 0:
|
||||
text = text[idx:]
|
||||
break
|
||||
cut_at = len(text)
|
||||
for m in ["Wil je dat ik", "Wil je deze", "Shall I", "Memo geschreven"]:
|
||||
idx = text.find(m)
|
||||
if 0 < idx < cut_at:
|
||||
cut_at = idx
|
||||
text = text[:cut_at].rstrip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _fallback_summary(data: dict[str, Any]) -> str:
|
||||
@@ -1019,17 +1007,12 @@ def _fallback_summary(data: dict[str, Any]) -> str:
|
||||
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"}
|
||||
metadata = {"stats": safe, "model": getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent", "type": "daily_ceo_report"}
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
|
||||
@@ -1067,7 +1050,7 @@ 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)
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
ai_part = ""
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"signup_url": "https://console.mistral.ai/",
|
||||
"hint": "EU-hosted · gratis proef tier.",
|
||||
},
|
||||
"hermes": {
|
||||
"label": "Hermes Agent (Herman)",
|
||||
"api_base_url": "http://10.4.7.10:8642/v1",
|
||||
"models": ["hermes-agent"],
|
||||
"default_model": "hermes-agent",
|
||||
"needs_key": True,
|
||||
"hint": "OpenAI-compatible API van Hermes Agent LXC — OpenCode eronder.",
|
||||
},
|
||||
"custom_openai": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
"api_base_url": "",
|
||||
@@ -124,6 +132,26 @@ def _env_openrouter_provider() -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _env_hermes_provider() -> dict[str, Any] | None:
|
||||
from app.config import settings as app_settings
|
||||
|
||||
key = (app_settings.HERMES_API_KEY or "").strip()
|
||||
base = (app_settings.HERMES_API_BASE or "").strip()
|
||||
if not key or not base:
|
||||
return None
|
||||
return {
|
||||
"id": -2,
|
||||
"label": "Hermes Agent (Herman)",
|
||||
"provider_type": "hermes",
|
||||
"api_base_url": base.rstrip("/"),
|
||||
"api_key": key,
|
||||
"model": app_settings.HERMES_API_MODEL or "hermes-agent",
|
||||
"extra_config": {"temperature": 0.35, "max_tokens": 4096},
|
||||
}
|
||||
|
||||
|
||||
def list_presets() -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for key, meta in LLM_PRESETS.items():
|
||||
@@ -175,8 +203,24 @@ def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
|
||||
from app.config import settings as app_settings
|
||||
|
||||
backend = (app_settings.HERMAN_LLM_BACKEND or "auto").lower()
|
||||
hermes = _env_hermes_provider()
|
||||
if hermes and backend in ("hermes", "auto"):
|
||||
# Prefer Hermes when configured (Ollama is often offline)
|
||||
if backend == "hermes" or provider_id is None:
|
||||
row = get_provider(provider_id) if provider_id else None
|
||||
# Explicit provider_id still wins when set
|
||||
if provider_id and row:
|
||||
pass
|
||||
else:
|
||||
return hermes
|
||||
|
||||
row = get_provider(provider_id)
|
||||
if not row:
|
||||
if hermes:
|
||||
return hermes
|
||||
env_or = _env_openrouter_provider()
|
||||
if env_or:
|
||||
return env_or
|
||||
@@ -191,6 +235,8 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
|
||||
}
|
||||
env_or = _env_openrouter_provider()
|
||||
ptype = (row.get("provider_type") or "").lower()
|
||||
if hermes and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
|
||||
return hermes
|
||||
if env_or and ptype == "openrouter" and not (row.get("api_key") or "").strip():
|
||||
merged = dict(row)
|
||||
merged["api_key"] = env_or["api_key"]
|
||||
@@ -198,9 +244,7 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
|
||||
merged["model"] = env_or["model"]
|
||||
return merged
|
||||
if env_or and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
|
||||
# Optional: prefer env OpenRouter over default Ollama when key is set
|
||||
from app.config import settings as app_settings
|
||||
if (app_settings.HERMAN_LLM_BACKEND or "").lower() in ("router", "openrouter"):
|
||||
if backend in ("router", "openrouter"):
|
||||
return env_or
|
||||
return row
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
:root { --sidebar-width: 280px; }
|
||||
|
||||
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
|
||||
|
||||
.app-shell {
|
||||
@@ -10,7 +12,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 220px;
|
||||
width: var(--sidebar-width, 280px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,7 +23,7 @@
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-left: 220px;
|
||||
margin-left: var(--sidebar-width, 280px);
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -164,6 +166,8 @@
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Per-item accent colors */
|
||||
@@ -199,3 +203,32 @@
|
||||
}
|
||||
.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
|
||||
.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
|
||||
|
||||
|
||||
/* sidebar resize v11 */
|
||||
|
||||
.app-sidebar-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
background: transparent;
|
||||
}
|
||||
.app-sidebar-resizer:hover,
|
||||
.app-sidebar-resizer.is-dragging {
|
||||
background: rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
body.sidebar-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
body.sidebar-resizing iframe {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-sidebar-resizer { display: none; }
|
||||
}
|
||||
|
||||
@@ -894,3 +894,498 @@
|
||||
.herman-rss-side-item small { color: #64748b; font-size: 0.65rem; }
|
||||
.herman-rss-col-side .hm-chart-panel { margin-bottom: 0.75rem; }
|
||||
.herman-rss-col-side h4 { margin: 0 0 0.5rem; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
|
||||
/* CEO clean layout — briefing first, less noise */
|
||||
.hm-ceo-clean .hm-briefing-hero {
|
||||
order: -1;
|
||||
}
|
||||
.hm-briefing-clean {
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.hm-briefing-clean .briefing-card.summary {
|
||||
padding: 1.35rem 1.5rem;
|
||||
}
|
||||
.hm-briefing-clean .live-digest-body {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.hm-briefing-clean .live-digest-section {
|
||||
margin-bottom: 1.1rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.hm-briefing-clean .live-digest-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-section-title {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hm-ceo-clean .beurs-mini-chip {
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-eq { display: none; }
|
||||
@media (max-width: 960px) {
|
||||
.hm-briefing-clean { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Retail & markt removed from CEO overview — RSS lives in RSS tab */
|
||||
.hm-ceo-clean [data-widget="retail"] { display: none !important; }
|
||||
|
||||
/* CEO overview: only dagmemo, no horizon/activity */
|
||||
.hm-ceo-clean .briefing-horizon-col,
|
||||
.hm-ceo-clean .horizon-card,
|
||||
.hm-ceo-clean .hm-activity-timeline { display: none !important; }
|
||||
.hm-ceo-clean .hm-briefing-solo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hm-ceo-clean .hm-briefing-solo .briefing-card.summary {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Force CEO overview order (prefs/JS cannot override) */
|
||||
.hm-ceo-clean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hm-ceo-clean > [data-widget="kpis"] { order: 1; }
|
||||
.hm-ceo-clean > [data-widget="executive"] { order: 2; }
|
||||
.hm-ceo-clean > [data-widget="analytics"] { order: 3; }
|
||||
.hm-ceo-clean > [data-widget="briefing"] { order: 4; }
|
||||
.hm-ceo-clean > [data-widget="news"] { order: 5; }
|
||||
.hm-ceo-clean > [data-widget="feed"] { order: 6; }
|
||||
.hm-ceo-clean > [data-widget="approvals"] { order: 7; }
|
||||
.hm-ceo-clean > [data-widget="retail"] { order: 99; display: none !important; }
|
||||
|
||||
/* ═══ CEO PRO LAYOUT ═══ */
|
||||
.hm-ceo-pro {
|
||||
gap: 1.15rem;
|
||||
}
|
||||
.hm-ceo-pro .hm-dash-section {
|
||||
margin: 0;
|
||||
}
|
||||
.hm-ceo-pro .hm-section-title { display: block; }
|
||||
|
||||
/* KPI row — bold hierarchy */
|
||||
.hm-ceo-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.hm-ceo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hm-ceo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.hm-ceo-kpi-link { text-decoration: none; color: inherit; display: block; }
|
||||
.hm-ceo-kpi {
|
||||
background: linear-gradient(165deg, rgba(18, 28, 42, 0.95), rgba(8, 12, 20, 0.98));
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-top: 2px solid var(--kpi-accent, #00e5ff);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.05rem 0.9rem;
|
||||
min-height: 92px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.hm-ceo-kpi-link:hover .hm-ceo-kpi {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hm-ceo-kpi-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8b9cb3;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.hm-ceo-kpi-value {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hm-ceo-kpi-sub {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* B2B | B2C */
|
||||
.hm-ceo-brand-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hm-ceo-brand-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hm-ceo-brand-card {
|
||||
background: rgba(10, 16, 26, 0.92);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
padding: 1.1rem 1.2rem 1rem;
|
||||
}
|
||||
.hm-ceo-brand-card.brand-foodlinkk { border-left: 3px solid #38bdf8; }
|
||||
.hm-ceo-brand-card.brand-cucina { border-left: 3px solid #fb923c; }
|
||||
.hm-ceo-brand-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hm-ceo-brand-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.hm-ceo-brand-icon { font-size: 1.15rem; }
|
||||
.hm-ceo-brand-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hm-ceo-brand-list li {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
color: #cbd5e1;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid rgba(148,163,184,0.2);
|
||||
}
|
||||
|
||||
/* Focus + Trends */
|
||||
.hm-ceo-mid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hm-ceo-mid-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hm-ceo-panel {
|
||||
background: rgba(10, 16, 26, 0.92);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
.hm-ceo-panel header h3 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.hm-ceo-focus { border-top: 2px solid #f472b6; }
|
||||
.hm-ceo-trends { border-top: 2px solid #34d399; }
|
||||
.hm-ceo-action-list,
|
||||
.hm-ceo-trend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.hm-ceo-action-list li,
|
||||
.hm-ceo-trend-list li {
|
||||
font-size: 0.88rem;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hm-ceo-bullet {
|
||||
color: #f472b6;
|
||||
font-weight: 700;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.hm-ceo-trend-list li {
|
||||
color: #a7f3d0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Herman memo — collapsed by default */
|
||||
.hm-ceo-memo {
|
||||
margin-top: 0.9rem;
|
||||
background: rgba(10, 16, 26, 0.7);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-memo summary {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
list-style: none;
|
||||
}
|
||||
.hm-ceo-memo summary::-webkit-details-marker { display: none; }
|
||||
.hm-ceo-memo[open] summary { margin-bottom: 0.65rem; color: #cbd5e1; }
|
||||
.hm-ceo-memo-body {
|
||||
font-size: 0.86rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.hm-ceo-memo-body .live-digest-body {
|
||||
display: block !important;
|
||||
grid-template-columns: none !important;
|
||||
}
|
||||
|
||||
/* News collapsible */
|
||||
.hm-ceo-news-details {
|
||||
background: rgba(10, 16, 26, 0.85);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 1rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-news-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.hm-ceo-news-details summary::-webkit-details-marker { display: none; }
|
||||
.hm-ceo-news-details summary small {
|
||||
font-weight: 400;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.hm-ceo-news-details summary a { color: #38bdf8; }
|
||||
.hm-ceo-news-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hm-ceo-news-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.22);
|
||||
}
|
||||
.hm-ceo-news-list a {
|
||||
color: #e2e8f0;
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.hm-ceo-news-list a:hover { color: #7dd3fc; }
|
||||
.hm-ceo-news-list small { color: #64748b; font-size: 0.7rem; }
|
||||
|
||||
/* Hide noisy chrome on CEO pro */
|
||||
.hm-ceo-pro ~ .herman-browser,
|
||||
.herman-shell:has(.hm-ceo-pro) .hm-eq { display: none; }
|
||||
|
||||
.hm-ceo-memo-open {
|
||||
margin-top: 0.9rem;
|
||||
background: rgba(10, 16, 26, 0.85);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
.hm-ceo-memo-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
/* Pipeline HTML bars — geen Chart.js ghosting */
|
||||
.hm-pipeline-host { min-height: 140px; }
|
||||
.hm-pipeline-bars { padding: 0.25rem 0.15rem 0.5rem; }
|
||||
.hm-pipeline-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: 0.85rem; gap: 0.75rem;
|
||||
}
|
||||
.hm-pipeline-head strong { color: #f1f5f9; font-size: 1.05rem; }
|
||||
.hm-pipeline-head span { color: #94a3b8; font-size: 0.82rem; }
|
||||
.hm-pipeline-list { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.hm-pipeline-meta {
|
||||
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||
margin-bottom: 0.3rem; font-size: 0.82rem;
|
||||
}
|
||||
.hm-pipeline-stage { color: #e2e8f0; font-weight: 600; text-transform: capitalize; }
|
||||
.hm-pipeline-vals { color: #94a3b8; }
|
||||
.hm-pipeline-track {
|
||||
height: 10px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.06); overflow: hidden;
|
||||
}
|
||||
.hm-pipeline-fill {
|
||||
height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||
}
|
||||
.hm-pipeline-foot { margin: 0.85rem 0 0; font-size: 0.78rem; }
|
||||
.hm-pipeline-foot a { color: #38bdf8; text-decoration: none; }
|
||||
|
||||
/* CEO context-rich overview */
|
||||
.hm-overview-panel { border-color: rgba(0,229,255,0.14); }
|
||||
.hm-context-banner {
|
||||
background: linear-gradient(120deg, rgba(14, 116, 144, 0.18), rgba(15, 23, 42, 0.4));
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.hm-context-banner p {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hm-exec-grid-rich {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hm-exec-grid-rich { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.hm-exec-rich {
|
||||
background: rgba(8, 12, 20, 0.65);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.hm-exec-rich:hover { border-color: rgba(0,229,255,0.35); transform: translateY(-1px); }
|
||||
.hm-exec-rich .hm-exec-val { font-size: 1.2rem; font-weight: 700; color: #f8fafc; margin-top: 0.15rem; }
|
||||
.hm-exec-sub { font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
|
||||
.hm-context-block { margin-top: 0.95rem; }
|
||||
.hm-context-block h4 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.hm-deal-chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.hm-deal-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
background: rgba(0,0,0,0.28);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
text-decoration: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.hm-deal-chip span { color: #86efac; font-weight: 650; }
|
||||
.hm-deal-chip em { color: #94a3b8; font-style: normal; font-size: 0.7rem; }
|
||||
.hm-deal-chip-client span { color: #7dd3fc; }
|
||||
.hm-context-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.hm-context-list li { font-size: 0.84rem; color: #cbd5e1; }
|
||||
.hm-score {
|
||||
display: inline-block; margin-left: 0.25rem;
|
||||
font-size: 0.68rem; color: #fbbf24;
|
||||
background: rgba(251,191,36,0.12);
|
||||
border-radius: 4px; padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.hm-ceo-brand-card { min-height: 160px; }
|
||||
.hm-ceo-pro .hm-briefing-hero, .hm-ceo-pro .hm-ceo-mid { margin-top: 0.15rem; }
|
||||
|
||||
|
||||
/* v32-kpi-six — 6 kerncijfers */
|
||||
.hm-neo-kpi-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.hm-neo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.hm-neo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
/* v33: CEO pipeline compact — geen lege scroll-ruimte */
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-analytics-panel,
|
||||
.hm-ceo-pro .hm-dash-analytics .hm-analytics-panel {
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-host,
|
||||
.hm-ceo-pro .hm-pipeline-host,
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-chart-wrap,
|
||||
.hm-ceo-pro .hm-dash-analytics .hm-chart-wrap {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.hm-ceo-clean .hm-neo-charts,
|
||||
.hm-ceo-pro .hm-neo-charts {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-section-title {
|
||||
display: none; /* panel head is genoeg — geen dubbele Pipeline-titel */
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-bars {
|
||||
padding: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-list {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-foot {
|
||||
margin: 0.55rem 0 0;
|
||||
}
|
||||
|
||||
/* Briefing + nieuws compacter */
|
||||
.hm-ceo-clean .hm-ceo-mid-grid {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-panel {
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo {
|
||||
margin-top: 0.65rem;
|
||||
max-height: none;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo-body {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo:not([open]) .hm-ceo-memo-body {
|
||||
display: none;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-news-details {
|
||||
margin: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-action-list,
|
||||
.hm-ceo-clean .hm-ceo-trend-list {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Minder verticale gap tussen secties */
|
||||
.hm-ceo-pro {
|
||||
gap: 0.85rem !important;
|
||||
}
|
||||
.hm-ceo-clean .hm-dash-section {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,24 @@
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.persona-ceo .main-topnav:has(.rc-excel-page),
|
||||
.main-topnav:has(.rc-excel-page) {
|
||||
.persona-ceo .main-topnav:has(.rc-page.rc-mode-sheet),
|
||||
.main-topnav:has(.rc-page.rc-mode-sheet) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
/* Cockpit-modus: altijd donker, nooit Excel-grijs */
|
||||
.persona-ceo .main-topnav:has(.rc-page:not(.rc-mode-sheet)),
|
||||
.main-topnav:has(.rc-page:not(.rc-mode-sheet)) {
|
||||
background: transparent !important;
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet) {
|
||||
background: transparent !important;
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet) .rc-header h1 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet) .rc-subtitle {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.rc-toolbar {
|
||||
display: flex;
|
||||
@@ -371,12 +385,12 @@
|
||||
}
|
||||
|
||||
/* Status = gekleurde linkerrand, neutrale donkere rij */
|
||||
.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
|
||||
.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
|
||||
.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
|
||||
.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
|
||||
.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
|
||||
.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
|
||||
.rc-visual-row.rc-style-green td { box-shadow: none; }
|
||||
.rc-visual-row.rc-style-red td { box-shadow: none; }
|
||||
.rc-visual-row.rc-style-orange td { box-shadow: none; }
|
||||
.rc-visual-row.rc-style-yellow td { box-shadow: none; color: #fef9c3; }
|
||||
.rc-visual-row.rc-style-blue td { box-shadow: none; }
|
||||
.rc-visual-row.rc-style-white td { box-shadow: none; }
|
||||
|
||||
.rc-vdeal { font-weight: 700; }
|
||||
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -491,3 +505,454 @@
|
||||
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
|
||||
.rc-expand-inner { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
/* ── v10: leesbaarheid — géén witte achtergronden in cockpit ── */
|
||||
.rc-page:not(.rc-mode-sheet) {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.rc-cockpit .rc-visual-table,
|
||||
.rc-cockpit .rc-visual-table td,
|
||||
.rc-cockpit .rc-visual-table .rc-vcell {
|
||||
background-color: #151c28 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-left: 0 !important;
|
||||
border-right: 0 !important;
|
||||
}
|
||||
.rc-cockpit .rc-visual-head td {
|
||||
background-color: #1a2332 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
.rc-cockpit .rc-visual-goals td {
|
||||
background-color: #2a2410 !important;
|
||||
color: #fde68a !important;
|
||||
}
|
||||
.rc-cockpit .rc-visual-row:hover td {
|
||||
background-color: #1e2a3a !important;
|
||||
}
|
||||
.rc-cockpit .rc-visual-row.rc-row-open td {
|
||||
background-color: #1a3050 !important;
|
||||
}
|
||||
.rc-cockpit .rc-deal-name,
|
||||
.rc-cockpit .rc-vamount {
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.rc-cockpit .rc-vamount { color: #86efac !important; }
|
||||
.rc-cockpit .rc-vsteps { color: #cbd5e1 !important; }
|
||||
.rc-cockpit .rc-expand-row td,
|
||||
.rc-cockpit .rc-expand-inner {
|
||||
background-color: #0a0e12 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
.rc-cockpit .rc-summary-strip {
|
||||
background: #1a2332 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
.rc-cockpit .rc-sum-item strong { color: #86efac !important; }
|
||||
.rc-cockpit .rc-sum-label { color: #94a3b8 !important; }
|
||||
.rc-cockpit .rc-filter-search,
|
||||
.rc-cockpit input[type="search"],
|
||||
.rc-cockpit input[type="text"],
|
||||
.rc-cockpit select {
|
||||
background: #0f1419 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border: 1px solid rgba(148,163,184,0.25) !important;
|
||||
}
|
||||
.rc-cockpit .rc-filter-search::placeholder {
|
||||
color: #64748b !important;
|
||||
}
|
||||
/* Excel wit mag alleen in Bewerken-modus */
|
||||
.rc-page:not(.rc-mode-sheet) .rc-btn,
|
||||
.rc-page:not(.rc-mode-sheet) .rc-toggle-btn {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-color: rgba(148,163,184,0.3);
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet) .rc-toggle-btn.active {
|
||||
background: #14532d;
|
||||
color: #bbf7d0;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
.rc-visual-row td + td { box-shadow: none !important; }
|
||||
|
||||
|
||||
/* ── v11: wit vlak + verticale lijn weg ── */
|
||||
.main-topnav:has(.rc-page:not(.rc-mode-sheet)),
|
||||
.persona-ceo .main-topnav:has(.rc-page:not(.rc-mode-sheet)) {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet),
|
||||
.rc-page:not(.rc-mode-sheet) .rc-header,
|
||||
.rc-page:not(.rc-mode-sheet) .rc-cockpit {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.rc-page:not(.rc-mode-sheet) .rc-header h1 { color: #f1f5f9 !important; }
|
||||
.rc-page:not(.rc-mode-sheet) .rc-header .live-badge { color: #86efac; }
|
||||
|
||||
/* Geen statusstreep meer links in de tabel (= die witte verticale lijn) */
|
||||
.rc-cockpit .rc-visual-row td,
|
||||
.rc-cockpit .rc-visual-row td:first-child {
|
||||
box-shadow: none !important;
|
||||
border-left: 0 !important;
|
||||
}
|
||||
.rc-cockpit .rc-visual-wrap {
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Statuskleur als klein bolletje bij de dealnaam i.p.v. streep */
|
||||
.rc-deal-name::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.45rem;
|
||||
vertical-align: middle;
|
||||
background: #64748b;
|
||||
}
|
||||
.rc-style-green .rc-deal-name::before { background: #4ade80; }
|
||||
.rc-style-red .rc-deal-name::before { background: #f87171; }
|
||||
.rc-style-orange .rc-deal-name::before { background: #fb923c; }
|
||||
.rc-style-yellow .rc-deal-name::before { background: #facc15; }
|
||||
.rc-style-blue .rc-deal-name::before { background: #60a5fa; }
|
||||
.rc-style-white .rc-deal-name::before { background: #64748b; }
|
||||
|
||||
|
||||
/* ── v12: selecteerbare visuals ── */
|
||||
.rc-visual-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(148,163,184,0.22);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.rc-vis-btn {
|
||||
border: none;
|
||||
background: #121a24;
|
||||
color: #94a3b8;
|
||||
padding: 0.32rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rc-vis-btn + .rc-vis-btn { border-left: 1px solid rgba(148,163,184,0.18); }
|
||||
.rc-vis-btn.active {
|
||||
background: #14532d;
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.rc-vis-btn:hover { color: #e2e8f0; }
|
||||
|
||||
.rc-style-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rc-chip {
|
||||
border: 1px solid rgba(148,163,184,0.22);
|
||||
background: #121a24;
|
||||
color: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.rc-chip:hover { border-color: rgba(148,163,184,0.45); }
|
||||
.rc-chip.active {
|
||||
box-shadow: 0 0 0 1px rgba(56,189,248,0.55);
|
||||
color: #f8fafc;
|
||||
}
|
||||
.rc-chip-static { cursor: default; }
|
||||
.rc-chip-green { border-color: rgba(74,222,128,0.45); color: #86efac; }
|
||||
.rc-chip-red { border-color: rgba(248,113,113,0.45); color: #fca5a5; }
|
||||
.rc-chip-orange { border-color: rgba(251,146,60,0.45); color: #fdba74; }
|
||||
.rc-chip-yellow { border-color: rgba(250,204,21,0.45); color: #fde047; }
|
||||
.rc-chip-blue { border-color: rgba(96,165,250,0.45); color: #93c5fd; }
|
||||
.rc-chip-white { border-color: rgba(148,163,184,0.35); color: #94a3b8; }
|
||||
.rc-chip-green.active { background: rgba(22,101,52,0.45); }
|
||||
.rc-chip-red.active { background: rgba(127,29,29,0.4); }
|
||||
.rc-chip-orange.active { background: rgba(154,52,18,0.4); }
|
||||
.rc-chip-yellow.active { background: rgba(113,63,18,0.4); }
|
||||
.rc-chip-blue.active { background: rgba(30,64,175,0.4); }
|
||||
.rc-chip-white.active { background: rgba(51,65,85,0.5); }
|
||||
|
||||
.rc-goals-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.55rem;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: #1a1520;
|
||||
border: 1px solid rgba(250,204,21,0.22);
|
||||
color: #fde68a;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.rc-goals-banner em {
|
||||
margin-left: auto;
|
||||
color: #facc15;
|
||||
font-style: italic;
|
||||
}
|
||||
.rc-goals-sep { color: #78716c; }
|
||||
|
||||
/* Bars view */
|
||||
.rc-bars-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: #0b1017;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
.rc-bar-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
background: #141c28;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
.rc-bar-row:hover { border-color: rgba(148,163,184,0.28); background: #182232; }
|
||||
.rc-bar-row.active { border-color: rgba(56,189,248,0.4); background: #152033; }
|
||||
.rc-bar-row-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1fr) auto auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.rc-bar-name {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-bar-amt { font-weight: 800; color: #86efac; font-variant-numeric: tabular-nums; font-size: 0.86rem; }
|
||||
.rc-bar-share { font-size: 0.72rem; color: #94a3b8; min-width: 2.5rem; text-align: right; }
|
||||
.rc-bar-track {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-bar-fill { height: 100%; border-radius: 999px; min-width: 2px; }
|
||||
|
||||
/* Cards view */
|
||||
.rc-cards-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.rc-cards-view { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.rc-cards-view { grid-template-columns: 1fr; }
|
||||
}
|
||||
.rc-card {
|
||||
background: #141c28;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
cursor: pointer;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rc-card:hover { border-color: rgba(148,163,184,0.3); background: #182232; }
|
||||
.rc-card.active { border-color: rgba(56,189,248,0.4); background: #152033; }
|
||||
.rc-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rc-card-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #f8fafc;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.rc-card-metrics {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.rc-card-metrics div { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.rc-card-metrics span {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
}
|
||||
.rc-card-metrics strong {
|
||||
font-size: 0.92rem;
|
||||
color: #86efac;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rc-card-bar {
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-card-bar > div { height: 100%; border-radius: 999px; }
|
||||
.rc-card-steps {
|
||||
margin: 0;
|
||||
margin-top: auto;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Detail panel */
|
||||
.rc-detail {
|
||||
margin-top: 0.85rem;
|
||||
background: #0f1520;
|
||||
border: 1px solid rgba(56,189,248,0.22);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.rc-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.rc-detail-head h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.rc-detail-close {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
border-radius: 8px;
|
||||
width: 32px; height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rc-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0,1fr)) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.rc-detail-stat span {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.rc-detail-stat strong {
|
||||
font-size: 1.15rem;
|
||||
color: #86efac;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rc-detail-ring {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rc-detail-ring span {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.55);
|
||||
}
|
||||
.rc-detail-compare {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
height: 72px;
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: rgba(8,12,18,0.55);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rc-detail-compare-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
}
|
||||
.rc-detail-compare-col span { font-size: 0.62rem; color: #64748b; }
|
||||
.rc-detail-vbar {
|
||||
width: 28px;
|
||||
min-height: 4px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: #22c55e;
|
||||
}
|
||||
.rc-detail-vbar-year { background: #38bdf8; }
|
||||
.rc-detail-status { margin-bottom: 0.75rem; }
|
||||
.rc-detail-label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.rc-detail-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.rc-detail-steps h4 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
}
|
||||
.rc-detail-steps p {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.rc-detail-task { margin-top: 0.75rem; }
|
||||
.rc-detail-task .rc-inline select,
|
||||
.rc-detail-task .rc-inline input {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #0f1419 !important;
|
||||
color: #e2e8f0 !important;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rc-summary-strip { gap: 0.65rem 0.85rem; }
|
||||
.rc-style-dots { display: none !important; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rc-detail-grid { grid-template-columns: 1fr 1fr; }
|
||||
.rc-visual-toggle { order: 5; flex: 1 1 100%; }
|
||||
.rc-style-chips { order: 6; flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ window.BriefingCharts = (function () {
|
||||
|
||||
function typewriter(el, text, speed) {
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
return;
|
||||
if (typewriterTimer) clearInterval(typewriterTimer);
|
||||
el.textContent = '';
|
||||
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
|
||||
@@ -58,16 +60,81 @@ window.BriefingCharts = (function () {
|
||||
}
|
||||
|
||||
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 } } } },
|
||||
// Compact HTML bars — geen vaste canvas-hoogte / geen Chart.js
|
||||
if (!canvas) return;
|
||||
if (charts.pipeline) {
|
||||
try { charts.pipeline.destroy(); } catch (e) {}
|
||||
charts.pipeline = null;
|
||||
}
|
||||
var host = canvas.parentElement;
|
||||
if (!host) return;
|
||||
canvas.style.display = 'none';
|
||||
canvas.setAttribute('hidden', '');
|
||||
var panel = host.querySelector('.hm-pipeline-bars');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'hm-pipeline-bars';
|
||||
host.appendChild(panel);
|
||||
}
|
||||
|
||||
var rev = (stats.revenue && stats.revenue.stats) || {};
|
||||
var crmRows = stats.pipeline_by_stage || stats.deal_stages || [];
|
||||
var crmTotal = Math.round(stats.pipeline_eur || 0);
|
||||
var rows = [];
|
||||
|
||||
// Prefer revenue top deals when CRM pipeline is empty/useless
|
||||
var projects = (stats.revenue && (stats.revenue.projects || stats.revenue.top_projects)) || [];
|
||||
if (!projects.length && stats.revenue && stats.revenue.by_brand) {
|
||||
var bb = stats.revenue.by_brand;
|
||||
projects = [].concat(bb.cucina || [], bb.foodlinkk || [], bb.shared || [], bb.other || []);
|
||||
}
|
||||
if ((!crmRows || !crmRows.length || crmTotal === 0) && projects.length) {
|
||||
rows = projects
|
||||
.filter(function (p) { return Number(p.margin_month || 0) > 0; })
|
||||
.sort(function (a, b) { return Number(b.margin_month || 0) - Number(a.margin_month || 0); })
|
||||
.slice(0, 6)
|
||||
.map(function (p) {
|
||||
return { name: p.name || 'Deal', count: 1, value: Number(p.margin_month || 0) };
|
||||
});
|
||||
var sub = document.getElementById('ceo-pipeline-sub');
|
||||
if (sub) sub.textContent = 'top marge/mnd · Revenue';
|
||||
} else if (crmRows && crmRows.length) {
|
||||
rows = crmRows.map(function (r) {
|
||||
return {
|
||||
name: r.stage || r.name || 'Stage',
|
||||
count: r.count || r.deals || 0,
|
||||
value: Number(r.value || r.eur || r.pipeline_eur || 0),
|
||||
};
|
||||
});
|
||||
var sub2 = document.getElementById('ceo-pipeline-sub');
|
||||
if (sub2) sub2.textContent = 'CRM stages';
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
var dealCount = stats.deals || rev.active_deals || 0;
|
||||
panel.innerHTML =
|
||||
'<div class="hm-pipeline-head"><strong>' + dealCount + ' deals</strong><span>geen stage-waarde</span></div>' +
|
||||
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var maxV = Math.max.apply(null, rows.map(function (r) { return r.value || 0 }).concat([1]));
|
||||
var totalEur = rows.reduce(function (s, r) { return s + (r.value || 0); }, 0);
|
||||
var totalCnt = rows.reduce(function (s, r) { return s + (r.count || 0); }, 0);
|
||||
panel.innerHTML =
|
||||
'<div class="hm-pipeline-head">' +
|
||||
'<strong>€' + Math.round(totalEur).toLocaleString('nl-NL') + '</strong>' +
|
||||
'<span>' + (totalCnt || rows.length) + ' regels</span>' +
|
||||
'</div>' +
|
||||
'<div class="hm-pipeline-list">' + rows.map(function (r) {
|
||||
var pct = Math.max(4, Math.round(((r.value || 0) / maxV) * 100));
|
||||
return '<div class="hm-pipeline-row">' +
|
||||
'<div class="hm-pipeline-meta"><span class="hm-pipeline-stage">' + String(r.name).replace(/</g, '') + '</span>' +
|
||||
'<span class="hm-pipeline-vals">€' + Math.round(r.value || 0).toLocaleString('nl-NL') + '</span></div>' +
|
||||
'<div class="hm-pipeline-track"><div class="hm-pipeline-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
|
||||
}
|
||||
|
||||
function renderSentiment(canvas, stats) {
|
||||
@@ -236,6 +303,22 @@ window.BriefingCharts = (function () {
|
||||
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 'margin_month': {
|
||||
var rev = (stats.revenue && stats.revenue.stats) || {};
|
||||
return '€' + Math.round(rev.total_margin_month || 0).toLocaleString('nl-NL');
|
||||
}
|
||||
case 'margin_year': {
|
||||
var revY = (stats.revenue && stats.revenue.stats) || {};
|
||||
return '€' + Math.round(revY.total_margin_year || 0).toLocaleString('nl-NL');
|
||||
}
|
||||
case 'open_steps': {
|
||||
var revO = (stats.revenue && stats.revenue.stats) || {};
|
||||
return String(revO.open_objectives || 0);
|
||||
}
|
||||
case 'revenue_deals': {
|
||||
var revD = (stats.revenue && stats.revenue.stats) || {};
|
||||
return String(revD.active_deals || revD.active_projects || 0);
|
||||
}
|
||||
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);
|
||||
@@ -260,6 +343,13 @@ window.BriefingCharts = (function () {
|
||||
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
|
||||
switch (id) {
|
||||
case 'pipeline': return '72%';
|
||||
case 'margin_month': return '90%';
|
||||
case 'margin_year': return '85%';
|
||||
case 'open_steps': {
|
||||
var ro = (stats.revenue && stats.revenue.stats) || {};
|
||||
return Math.min(95, Math.round((ro.open_objectives || 0) / 50 * 100)) + '%';
|
||||
}
|
||||
case 'revenue_deals': return '80%';
|
||||
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%';
|
||||
@@ -276,17 +366,22 @@ window.BriefingCharts = (function () {
|
||||
if (!container) return;
|
||||
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
|
||||
var order = selectedKpis || window._dashboardKpis ||
|
||||
(window.DashboardLayout && window.DashboardLayout.CEO_KPIS) ||
|
||||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
|
||||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
|
||||
['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
|
||||
if (window.DashboardLayout && window.DashboardLayout.ceoKpis) {
|
||||
order = window.DashboardLayout.ceoKpis({ dashboard_kpis: order });
|
||||
}
|
||||
order = order.slice(0, 6);
|
||||
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',
|
||||
id: id, 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>';
|
||||
container.innerHTML = '<p class="empty-state">Geen KPI\'s</p>';
|
||||
return;
|
||||
}
|
||||
container.className = 'hm-neo-kpi-row';
|
||||
@@ -299,6 +394,7 @@ window.BriefingCharts = (function () {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
function renderRssFeed(container, stats) {
|
||||
if (!container) return;
|
||||
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
|
||||
@@ -347,67 +443,94 @@ window.BriefingCharts = (function () {
|
||||
|
||||
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>';
|
||||
var rev = (stats.revenue && stats.revenue.stats) || {};
|
||||
var by = (stats.revenue && stats.revenue.by_brand) || {};
|
||||
var cu = by.cucina || [];
|
||||
var fl = by.foodlinkk || [];
|
||||
var clients = stats.recent_clients || [];
|
||||
var opp = stats.top_opportunities || [];
|
||||
var deals = stats.deals || 0;
|
||||
var pipeline = Math.round(stats.pipeline_eur || 0);
|
||||
var marginM = Math.round(rev.total_margin_month || 0);
|
||||
var openObj = rev.open_objectives || 0;
|
||||
|
||||
// Narrative context
|
||||
var narrative = [];
|
||||
if (marginM > 0) narrative.push('Cucina + Foodlinkk draaien €' + marginM.toLocaleString('nl-NL') + '/mnd marge over ' + (rev.active_projects || 0) + ' projecten.');
|
||||
if (openObj) narrative.push(openObj + ' open stappen vragen opvolging.');
|
||||
if ((stats.crm_partnerships || 0) > 0) narrative.push(stats.crm_partnerships + ' retail-partnerships · ' + (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen bereikbaar.');
|
||||
if (pipeline === 0 && deals > 0) narrative.push(deals + ' CRM-deals staan klaar — pipeline-waarde nog €0 (nog geen getekende budgetten).');
|
||||
if (!narrative.length) narrative.push('Live business data laden…');
|
||||
|
||||
var topDeals = cu.filter(function (p) { return p.margin_month; }).slice(0, 3);
|
||||
var html = '';
|
||||
html += '<div class="hm-context-banner"><p>' + escHtml(narrative.join(' ')) + '</p></div>';
|
||||
html += '<div class="hm-exec-grid hm-exec-grid-rich">' +
|
||||
[
|
||||
{ icon: '💶', label: 'Marge / mnd', val: '€' + marginM.toLocaleString('nl-NL'), sub: (rev.active_projects || 0) + ' projecten', link: '/revenue-cockpit' },
|
||||
{ icon: '💰', label: 'CRM pipeline', val: '€' + pipeline.toLocaleString('nl-NL'), sub: deals + ' deals', link: '/deals' },
|
||||
{ icon: '🤝', label: 'Klanten', val: String(stats.clients_total || stats.clients || 0), sub: (stats.clients_active || 0) + ' actief', link: '/clients' },
|
||||
{ icon: '🏪', label: 'Partnerships', val: String(stats.crm_partnerships || 0), sub: (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen', link: '/retail' },
|
||||
].map(function (it) {
|
||||
return '<a href="' + it.link + '" class="hm-exec-item hm-exec-rich">' +
|
||||
'<span class="hm-exec-icon">' + it.icon + '</span>' +
|
||||
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div>' +
|
||||
'<div class="hm-exec-sub">' + it.sub + '</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>';
|
||||
|
||||
if (topDeals.length) {
|
||||
html += '<div class="hm-context-block"><h4>Top Cucina deals</h4><div class="hm-deal-chips">';
|
||||
topDeals.forEach(function (d) {
|
||||
html += '<a class="hm-deal-chip" href="/revenue-cockpit"><strong>' + escHtml(d.name) + '</strong>' +
|
||||
'<span>€' + Math.round(d.margin_month || 0).toLocaleString('nl-NL') + '/m</span>' +
|
||||
(d.open_objectives ? '<em>' + d.open_objectives + ' stappen</em>' : '') + '</a>';
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
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>';
|
||||
|
||||
if (clients.length) {
|
||||
html += '<div class="hm-context-block"><h4>Recente klanten</h4><div class="hm-deal-chips">';
|
||||
clients.slice(0, 4).forEach(function (c) {
|
||||
html += '<a class="hm-deal-chip hm-deal-chip-client" href="/clients"><strong>' + escHtml(c.name || '') + '</strong>' +
|
||||
'<span>' + escHtml(c.stage || '') + '</span></a>';
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
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>';
|
||||
|
||||
if (opp.length) {
|
||||
html += '<div class="hm-context-block"><h4>Halal-kansen</h4><ul class="hm-context-list">';
|
||||
opp.slice(0, 3).forEach(function (o) {
|
||||
var score = Math.round(o.halal_opportunity_score || o.score || 0);
|
||||
html += '<li><strong>' + escHtml(o.chain || o.name || '') + '</strong> · ' +
|
||||
escHtml(o.city || '') + ' <span class="hm-score">score ' + score + '</span></li>';
|
||||
});
|
||||
html += '</ul></div>';
|
||||
}
|
||||
|
||||
html += '<p class="hm-exec-footer">' +
|
||||
'<a href="/revenue-cockpit">Revenue Cockpit</a> · <a href="/retail">Retail 360</a> · ' +
|
||||
'<a href="/clients">Clients</a> · <a href="/deals">Deals</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', ' ');
|
||||
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) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
|
||||
var longEl = document.getElementById('briefing-longterm-list');
|
||||
if (longEl) setTimeout(function () {
|
||||
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
|
||||
}, 300);
|
||||
if (!sumEl || !content) return;
|
||||
sumEl.dataset.aiMemo = '1';
|
||||
// Markdown-achtige tekst direct tonen (geen typewriter)
|
||||
var html = escHtml(content)
|
||||
.replace(/^### (.+)$/gm, '<h5>$1</h5>')
|
||||
.replace(/^## (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^# (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, function (m) { return '<ul class="live-digest-list">' + m + '</ul>'; })
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
sumEl.innerHTML = '<div class="hm-ceo-memo-md"><p>' + html + '</p></div>';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
@@ -441,152 +564,77 @@ window.BriefingCharts = (function () {
|
||||
}
|
||||
|
||||
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);
|
||||
// Typewriter uit — altijd direct tonen
|
||||
fillDigestInstant(container, digest);
|
||||
}
|
||||
|
||||
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; };
|
||||
fillListInstant(listEl, items || []);
|
||||
}
|
||||
|
||||
|
||||
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 || []);
|
||||
function fillSimpleList(el, items, emptyMsg) {
|
||||
if (!el) return;
|
||||
var rows = (items || []).filter(Boolean);
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<li class="muted">' + escHtml(emptyMsg || 'Geen items') + '</li>';
|
||||
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);
|
||||
el.innerHTML = rows.map(function (x) {
|
||||
if (typeof x === 'string') return '<li><span class="hm-ceo-bullet">→</span> ' + escHtml(x) + '</li>';
|
||||
var title = escHtml(x.title || x);
|
||||
var link = x.link ? ('<a href="' + escHtml(x.link) + '" target="_blank" rel="noopener">' + title + '</a>') : title;
|
||||
var src = x.source ? ('<small>' + escHtml(x.source) + '</small>') : '';
|
||||
return '<li>' + link + src + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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 });
|
||||
function fillBrandCard(el, lines) {
|
||||
if (!el) return;
|
||||
var list = el.querySelector('.hm-ceo-brand-list') || el;
|
||||
var rows = (lines || []).filter(Boolean);
|
||||
list.innerHTML = rows.length
|
||||
? rows.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
|
||||
: '<li class="muted">Geen data</li>';
|
||||
}
|
||||
|
||||
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 renderCeoDigest(digest) {
|
||||
if (!digest) return;
|
||||
var b2b = digest.b2b, b2c = digest.b2c, focus = digest.focus, trends = digest.trends, news = digest.news;
|
||||
if (!b2b || !b2c) {
|
||||
(digest.sections || []).forEach(function (s) {
|
||||
if (s.brand === 'foodlinkk') b2b = s.lines;
|
||||
if (s.brand === 'cucina') b2c = s.lines;
|
||||
if (s.brand === 'platform' || (s.title || '').indexOf('Focus') >= 0) focus = s.lines;
|
||||
});
|
||||
}
|
||||
fillBrandCard(document.getElementById('ceo-b2b'), b2b);
|
||||
fillBrandCard(document.getElementById('ceo-b2c'), b2c);
|
||||
fillSimpleList(document.getElementById('ceo-focus-list'), focus || digest.actions, 'Geen urgente acties');
|
||||
fillSimpleList(document.getElementById('ceo-trends-list'), trends, 'Geen trends');
|
||||
fillSimpleList(document.getElementById('ceo-news-list'), news, 'Geen nieuws — open RSS tab');
|
||||
}
|
||||
|
||||
function renderLiveSummary(digest, pollRefresh) {
|
||||
if (!digest) return;
|
||||
var meta = document.getElementById('briefing-meta');
|
||||
lastDigestSig = digestSignature(digest);
|
||||
if (meta) {
|
||||
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
|
||||
meta.textContent = 'Live · ververst ' + ts + ' UTC';
|
||||
}
|
||||
if (document.querySelector('.hm-ceo-pro')) {
|
||||
renderCeoDigest(digest);
|
||||
return;
|
||||
}
|
||||
var sumEl = document.getElementById('briefing-summary-text');
|
||||
if (sumEl) fillDigestInstant(sumEl, digest);
|
||||
}
|
||||
|
||||
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
|
||||
@@ -597,17 +645,11 @@ window.BriefingCharts = (function () {
|
||||
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);
|
||||
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>';
|
||||
}
|
||||
// activity log removed from CEO overview
|
||||
renderPipeline(document.getElementById('chart-pipeline'), stats);
|
||||
// CEO overview: geen sentiment/words/agents-charts (lege hoogte + troep)
|
||||
if (!document.querySelector('.hm-ceo-clean')) {
|
||||
renderWords(document.getElementById('chart-words'), stats);
|
||||
if (window.VizEngine) {
|
||||
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
|
||||
@@ -617,6 +659,7 @@ window.BriefingCharts = (function () {
|
||||
renderAgents(document.getElementById('chart-agents'), stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(root, stats, content, createdAt, vizMode) {
|
||||
renderLive(root, stats, vizMode);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
var WIDGET_META = {
|
||||
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
||||
executive: { title: 'Alles op een rij', icon: '📋' },
|
||||
briefing: { title: 'Herman briefing', icon: '📝' },
|
||||
retail: { title: 'Retail operatie', icon: '🏪' },
|
||||
briefing: { title: 'Focus & trends', icon: '🎯' },
|
||||
news: { title: 'Laatste nieuws', icon: '📰' },
|
||||
analytics: { title: 'Data & analytics', icon: '📈' },
|
||||
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
||||
feed: { title: 'Agent feed', icon: '⚡' },
|
||||
@@ -26,10 +26,16 @@
|
||||
nas_docs: { label: 'NAS documenten', sub: 'geanalyseerd', icon: '📊', color: '#818cf8', link: '/documents' },
|
||||
promo_campaigns: { label: 'Promo campagnes', sub: 'actief', icon: '📁', color: '#c084fc', link: '/marketing?tab=reclame' },
|
||||
market_best: { label: 'Aandeel top', sub: 'retail markt', icon: '📈', color: '#34d399', link: '/analytics' },
|
||||
margin_month: { label: 'Marge / mnd', sub: 'Revenue Cockpit', icon: '💶', color: '#fbbf24', link: '/revenue-cockpit' },
|
||||
margin_year: { label: 'Marge / jr', sub: 'Revenue Cockpit', icon: '📅', color: '#38bdf8', link: '/revenue-cockpit' },
|
||||
open_steps: { label: 'Open stappen', sub: 'opvolging nodig', icon: '🎯', color: '#f472b6', link: '/revenue-cockpit' },
|
||||
revenue_deals: { label: 'Actieve deals', sub: 'met marge', icon: '💼', color: '#4ade80', link: '/revenue-cockpit' },
|
||||
};
|
||||
|
||||
var DEFAULT_LAYOUT = Object.keys(WIDGET_META);
|
||||
var DEFAULT_KPIS = ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
|
||||
var DEFAULT_LAYOUT = ['kpis', 'executive', 'analytics', 'briefing', 'news', 'approvals', 'feed'];
|
||||
var CEO_LAYOUT = ['kpis', 'executive', 'analytics', 'briefing', 'news'];
|
||||
var DEFAULT_KPIS = ['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
|
||||
var CEO_KPIS = ['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
|
||||
|
||||
function loadPrefs() {
|
||||
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
|
||||
@@ -73,21 +79,42 @@
|
||||
return savePrefs(patch);
|
||||
}
|
||||
|
||||
function ceoKpis(prefs) {
|
||||
var k = (prefs && prefs.dashboard_kpis) || [];
|
||||
if (!k.length || k.indexOf('margin_month') < 0) return CEO_KPIS.slice();
|
||||
// ensure core revenue KPIs present without dropping user extras
|
||||
var need = ['margin_month', 'open_steps', 'revenue_deals'];
|
||||
need.forEach(function (id) {
|
||||
if (k.indexOf(id) < 0) k = [id].concat(k);
|
||||
});
|
||||
return k.slice(0, 6);
|
||||
}
|
||||
|
||||
function visibleLayout(prefs) {
|
||||
var layout = prefs.dashboard_layout || DEFAULT_LAYOUT.slice();
|
||||
var widgets = prefs.dashboard_widgets || defaultWidgets();
|
||||
return layout.filter(function (id) { return widgets[id] !== false; });
|
||||
var rest = (prefs.dashboard_layout || DEFAULT_LAYOUT.slice()).filter(function (id) {
|
||||
return CEO_LAYOUT.indexOf(id) < 0 && widgets[id] !== false;
|
||||
});
|
||||
var top = CEO_LAYOUT.filter(function (id) { return widgets[id] !== false; });
|
||||
return top.concat(rest);
|
||||
}
|
||||
|
||||
function applyOrder(container, order) {
|
||||
if (!container) return;
|
||||
var map = {};
|
||||
var leftovers = [];
|
||||
Array.from(container.children).forEach(function (el) {
|
||||
if (el.dataset && el.dataset.widget) map[el.dataset.widget] = el;
|
||||
else leftovers.push(el);
|
||||
});
|
||||
order.forEach(function (id) {
|
||||
if (map[id]) container.appendChild(map[id]);
|
||||
var seen = {};
|
||||
(order || []).forEach(function (id) {
|
||||
if (map[id]) { container.appendChild(map[id]); seen[id] = true; }
|
||||
});
|
||||
Object.keys(map).forEach(function (id) {
|
||||
if (!seen[id]) container.appendChild(map[id]);
|
||||
});
|
||||
leftovers.forEach(function (el) { container.appendChild(el); });
|
||||
}
|
||||
|
||||
function applyVisibility(container, widgets) {
|
||||
@@ -257,12 +284,15 @@
|
||||
WIDGET_META: WIDGET_META,
|
||||
KPI_META: KPI_META,
|
||||
DEFAULT_KPIS: DEFAULT_KPIS,
|
||||
CEO_KPIS: CEO_KPIS,
|
||||
ceoKpis: ceoKpis,
|
||||
loadPrefs: loadPrefs,
|
||||
saveLayout: saveLayout,
|
||||
saveViz: saveViz,
|
||||
savePrefs: savePrefs,
|
||||
saveDashboardConfig: saveDashboardConfig,
|
||||
visibleLayout: visibleLayout,
|
||||
CEO_LAYOUT: CEO_LAYOUT,
|
||||
applyOrder: applyOrder,
|
||||
applyVisibility: applyVisibility,
|
||||
initDrag: initDrag,
|
||||
|
||||
@@ -5,6 +5,7 @@ function revenueCockpit() {
|
||||
savedAt: '',
|
||||
_pollStop: null,
|
||||
viewMode: 'cockpit',
|
||||
visualMode: 'table',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
@@ -45,6 +46,8 @@ function revenueCockpit() {
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const saved = localStorage.getItem('rc-visual-mode');
|
||||
if (saved === 'table' || saved === 'bars' || saved === 'cards') this.visualMode = saved;
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (window.CockpitLive) {
|
||||
@@ -126,6 +129,53 @@ function revenueCockpit() {
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
|
||||
setVisualMode(mode) {
|
||||
if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return;
|
||||
this.visualMode = mode;
|
||||
localStorage.setItem('rc-visual-mode', mode);
|
||||
},
|
||||
|
||||
styleLabel(style) {
|
||||
const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' };
|
||||
return map[style] || 'Neutraal';
|
||||
},
|
||||
|
||||
rankedRows() {
|
||||
const rows = this.visualRows().slice();
|
||||
rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0));
|
||||
return rows;
|
||||
},
|
||||
|
||||
cardRows() {
|
||||
return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim());
|
||||
},
|
||||
|
||||
async setCockpitStyle(style) {
|
||||
const p = this.selectedProject;
|
||||
if (!p) return;
|
||||
if (!p.db_id) {
|
||||
p.row_style = style;
|
||||
if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + p.db_id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
p.row_style = style;
|
||||
const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p));
|
||||
if (pi >= 0) this.projects[pi].row_style = style;
|
||||
const di = this.dbProjects.findIndex((x) => x.id === p.db_id);
|
||||
if (di >= 0) this.dbProjects[di].row_style = style;
|
||||
if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success');
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
|
||||
@@ -20,17 +20,19 @@
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=22" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=10" />
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" />
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
|
||||
<aside class="app-sidebar" :style="'--sidebar-width:' + sidebarWidth + 'px'" @click="if($event.target.closest('a')) navOpen=false">
|
||||
<div class="app-sidebar-resizer" title="Sleep om breder te maken"
|
||||
@mousedown.prevent="startSidebarResize($event)"></div>
|
||||
<div class="app-sidebar-brand">
|
||||
<p class="brand-title">Cucina · Foodlinkk</p>
|
||||
<p class="brand-name" x-text="brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'">Cucina B2C</p>
|
||||
@@ -222,6 +224,7 @@
|
||||
brand: 'cucina',
|
||||
drawerOpen: false,
|
||||
navOpen: false,
|
||||
sidebarWidth: 280,
|
||||
showInstall: false,
|
||||
_deferredPrompt: null,
|
||||
clockDate: '—',
|
||||
@@ -242,9 +245,38 @@
|
||||
}
|
||||
if (window.I18n) await window.I18n.init();
|
||||
this.locale = window.I18n ? window.I18n.locale() : 'nl';
|
||||
this.initSidebarWidth();
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
},
|
||||
initSidebarWidth() {
|
||||
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
|
||||
if (!isFinite(saved)) saved = 280;
|
||||
this.sidebarWidth = Math.min(420, Math.max(220, saved));
|
||||
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
|
||||
},
|
||||
startSidebarResize(e) {
|
||||
var self = this;
|
||||
var startX = e.clientX;
|
||||
var startW = this.sidebarWidth;
|
||||
var handle = e.currentTarget;
|
||||
handle.classList.add('is-dragging');
|
||||
document.body.classList.add('sidebar-resizing');
|
||||
function onMove(ev) {
|
||||
var next = Math.min(420, Math.max(220, startW + (ev.clientX - startX)));
|
||||
self.sidebarWidth = next;
|
||||
document.documentElement.style.setProperty('--sidebar-width', next + 'px');
|
||||
}
|
||||
function onUp() {
|
||||
handle.classList.remove('is-dragging');
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
localStorage.setItem('flk-sidebar-width', String(self.sidebarWidth));
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
},
|
||||
setBrand(b) {
|
||||
if (b === this.brand) return;
|
||||
this.brand = b;
|
||||
|
||||
@@ -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=23" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo">
|
||||
@@ -12,7 +12,7 @@
|
||||
<div>
|
||||
<p class="hm-hero-eyebrow">Foodlinkk Command Center</p>
|
||||
<h1>CEO Dashboard <span class="live-badge hm-live-badge">Live</span></h1>
|
||||
<p class="hero-sub">Cucina B2C · retail · pipeline · Herman briefing</p>
|
||||
<p class="hero-sub">Foodlinkk B2B · Cucina B2C</p>
|
||||
</div>
|
||||
<div class="hm-hero-status" id="hero-live-status">
|
||||
<span class="hm-live-dot"></span>
|
||||
@@ -20,14 +20,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-hero-links beurs-mini-row">
|
||||
<a href="/clients" class="beurs-mini-chip">👥 Klanten</a>
|
||||
<a href="/deals" class="beurs-mini-chip">💼 Deals</a>
|
||||
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
|
||||
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
|
||||
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
|
||||
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
|
||||
<a href="/retail" class="beurs-mini-chip">🛒 Retail</a>
|
||||
<a href="/analytics" class="beurs-mini-chip">📊 Analytics</a>
|
||||
<a href="/clients" class="beurs-mini-chip">Klanten</a>
|
||||
<a href="/deals" class="beurs-mini-chip">Deals</a>
|
||||
<a href="/revenue-cockpit" class="beurs-mini-chip">Revenue</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,12 +44,9 @@
|
||||
<div class="briefing-meta" id="briefing-meta"></div>
|
||||
</div>
|
||||
<div class="herman-briefing-actions">
|
||||
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? '✓ Klaar' : '⚙ Dashboard instellen'"></button>
|
||||
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
|
||||
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
|
||||
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
|
||||
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
|
||||
<a href="/analytics" class="btn">Analytics</a>
|
||||
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">Genereer briefing</button>
|
||||
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">Live data</button>
|
||||
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? 'Klaar' : 'Indeling'"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -76,105 +68,79 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div id="viz-toolbar" class="viz-toolbar" x-show="editLayout" x-cloak></div>
|
||||
<div id="dashboard-customize" class="hm-customize-wrap" x-show="editLayout" x-cloak></div>
|
||||
|
||||
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
|
||||
<div id="briefing-dashboard" class="hm-dash-layout hm-ceo-clean hm-ceo-pro" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
|
||||
|
||||
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · CEO KPI's</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's <small class="hm-widget-hint" x-show="editLayout">— kies KPI's hieronder</small></h3>
|
||||
<section class="hm-dash-section hm-dash-full hm-ceo-kpis" data-widget="kpis">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · KPI's</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Kerncijfers</h3>
|
||||
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full" data-widget="executive">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Executive</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Overzicht</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Overzicht</h3>
|
||||
<div class="hm-chart-panel hm-overview-panel">
|
||||
<div class="hm-chart-head"><h4>Waar staan we</h4><small>live context</small></div>
|
||||
<div id="briefing-executive-summary"></div>
|
||||
</div>
|
||||
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full" data-widget="briefing">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
|
||||
<h3 class="hm-section-title">Herman briefing</h3>
|
||||
<div class="briefing-summary-grid">
|
||||
<div class="briefing-card summary">
|
||||
<h4>Samenvatting · live</h4>
|
||||
<div id="briefing-summary-text" class="live-digest-body"></div>
|
||||
</div>
|
||||
<div class="briefing-card hm-activity-card">
|
||||
<div class="hm-card-head">
|
||||
<h4>Activiteitenlog</h4>
|
||||
<a href="/agents" class="hm-card-link">Agents →</a>
|
||||
</div>
|
||||
<ul id="briefing-activity-log" class="hm-activity-timeline"></ul>
|
||||
</div>
|
||||
<div class="briefing-horizon-col">
|
||||
<div class="horizon-card short">
|
||||
<h4>⚡ Korte termijn</h4>
|
||||
<ul id="briefing-actions-list"></ul>
|
||||
</div>
|
||||
<div class="horizon-card long">
|
||||
<h4>🎯 Lange termijn</h4>
|
||||
<ul id="briefing-longterm-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section" data-widget="retail">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
|
||||
<h3 class="hm-section-title">Retail operatie</h3>
|
||||
<div class="hm-dash-two-col">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub →</a></small></div>
|
||||
<div id="briefing-retail"></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
|
||||
<div id="briefing-milestones"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
|
||||
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
|
||||
<div id="briefing-food-highlights" class="hm-highlight-grid"></div>
|
||||
<div class="hm-ceo-brand-grid" style="margin-top:0.9rem">
|
||||
<article class="hm-ceo-brand-card brand-foodlinkk" id="ceo-b2b">
|
||||
<header><span class="hm-ceo-brand-icon">🌍</span><h3>Foodlinkk B2B</h3></header>
|
||||
<ul class="hm-ceo-brand-list"><li class="muted">Laden…</li></ul>
|
||||
</article>
|
||||
<article class="hm-ceo-brand-card brand-cucina" id="ceo-b2c">
|
||||
<header><span class="hm-ceo-brand-icon">🍽️</span><h3>Cucina B2C</h3></header>
|
||||
<ul class="hm-ceo-brand-list"><li class="muted">Laden…</li></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full hm-dash-analytics" data-widget="analytics">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Analytics</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Pipeline</div>
|
||||
<div class="hm-neo-charts">
|
||||
<div class="hm-chart-panel hm-analytics-panel">
|
||||
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
|
||||
<div id="viz-sentiment-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel hm-analytics-panel">
|
||||
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
|
||||
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="hm-chart-head"><h4>Pipeline</h4><small id="ceo-pipeline-sub">revenue + CRM</small></div>
|
||||
<div class="hm-chart-wrap hm-pipeline-host"><canvas id="chart-pipeline" hidden></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hidden stubs so older renderers don't throw -->
|
||||
<canvas id="chart-sentiment" hidden></canvas>
|
||||
<div id="viz-sentiment-alt" hidden></div>
|
||||
<canvas id="chart-words" hidden></canvas>
|
||||
</section>
|
||||
|
||||
<div class="hub-grid hm-dash-feed" data-widget="feed">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Feed</div>
|
||||
<section class="panel hm-dash-full">
|
||||
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
|
||||
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
|
||||
<section class="hm-dash-section hm-dash-full hm-ceo-mid" data-widget="briefing">
|
||||
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
|
||||
<h3 class="hm-section-title">Herman briefing</h3>
|
||||
<div class="hm-ceo-mid-grid">
|
||||
<article class="hm-ceo-panel hm-ceo-focus">
|
||||
<header><h3>🎯 Focus vandaag</h3></header>
|
||||
<ul id="ceo-focus-list" class="hm-ceo-action-list"><li class="muted">Laden…</li></ul>
|
||||
</article>
|
||||
<article class="hm-ceo-panel hm-ceo-trends">
|
||||
<header><h3>📈 Trends</h3></header>
|
||||
<ul id="ceo-trends-list" class="hm-ceo-trend-list"><li class="muted">Laden…</li></ul>
|
||||
</article>
|
||||
</div>
|
||||
<details class="hm-ceo-memo" id="ceo-memo-wrap">
|
||||
<summary class="hm-ceo-memo-label">Dagmemo <small>klik om te openen</small></summary>
|
||||
<div id="briefing-summary-text" class="hm-ceo-memo-body"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full hm-ceo-news" data-widget="news">
|
||||
<details class="hm-ceo-news-details">
|
||||
<summary>📰 Laatste nieuws <small>max 5 · <a href="/?tab=rss" @click.prevent="setHermanTab('rss')">alle RSS →</a></small></summary>
|
||||
<div id="ceo-news-list" class="hm-ceo-news-list"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="hub-grid hm-dash-feed" data-widget="feed" hidden>
|
||||
<div id="dash-projects-mini"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -263,8 +229,8 @@
|
||||
{% endblock %}
|
||||
{% 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=19"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=11"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=29"></script>
|
||||
<script>
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
@@ -282,7 +248,11 @@ function dashboardBriefing() {
|
||||
_prefs: null, _setDragMode: null, _saveTimer: null,
|
||||
applyDashboardPrefs(prefs) {
|
||||
this._prefs = prefs;
|
||||
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
|
||||
window._dashboardKpis = (DashboardLayout.ceoKpis ? DashboardLayout.ceoKpis(prefs) : (prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS));
|
||||
if (!localStorage.getItem('rc-kpi-migrated-v2')) {
|
||||
DashboardLayout.saveDashboardConfig(null, null, window._dashboardKpis);
|
||||
localStorage.setItem('rc-kpi-migrated-v2', '1');
|
||||
}
|
||||
const dash = document.getElementById('briefing-dashboard');
|
||||
const order = DashboardLayout.visibleLayout(prefs);
|
||||
DashboardLayout.applyOrder(dash, order);
|
||||
@@ -359,7 +329,7 @@ function dashboardBriefing() {
|
||||
this._prefs = prefs;
|
||||
this._vizMode = prefs.global_viz_mode || 'neo-bars';
|
||||
window._dashboardVizMode = this._vizMode;
|
||||
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
|
||||
window._dashboardKpis = (DashboardLayout.ceoKpis ? DashboardLayout.ceoKpis(prefs) : (prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS));
|
||||
const dash = document.getElementById('briefing-dashboard');
|
||||
this.applyDashboardPrefs(prefs);
|
||||
this._setDragMode = DashboardLayout.initDrag(dash, (order) => {
|
||||
@@ -391,7 +361,8 @@ function dashboardBriefing() {
|
||||
await this.loadProjectsMini();
|
||||
if (this.liveDigest) {
|
||||
BriefingCharts.renderLiveSummary(this.liveDigest, false);
|
||||
} else if (this.content) {
|
||||
}
|
||||
if (this.content) {
|
||||
BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
}
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
@@ -405,8 +376,6 @@ function dashboardBriefing() {
|
||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||
await this.loadRssTab();
|
||||
if (this._stats) {
|
||||
BriefingCharts.renderRetail(document.getElementById('briefing-retail'), this._stats);
|
||||
BriefingCharts.renderFoodHighlights(document.getElementById('briefing-food-highlights'), this._stats);
|
||||
BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, window._dashboardKpis);
|
||||
}
|
||||
if (toast) Cockpit.toast('RSS bijgewerkt (' + (this.rssTotal || 0) + ' items)', 'success');
|
||||
@@ -509,7 +478,6 @@ function dashboardBriefing() {
|
||||
} catch (e) {}
|
||||
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 || []);
|
||||
this.updateLiveMeta(r.at, this.liveDigest);
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
||||
@@ -567,7 +535,6 @@ function dashboardBriefing() {
|
||||
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 || []);
|
||||
document.getElementById('briefing-meta').textContent = 'Rapport: ' + this.createdAt.substring(0, 19).replace('T', ' ') + ' UTC';
|
||||
Cockpit.toast('Dagrapport klaar!', 'success');
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=12" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
@@ -41,17 +41,36 @@
|
||||
<span class="rc-sum-label">Deals</span>
|
||||
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-toggle" role="group" aria-label="Weergave">
|
||||
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'table' }" @click="setVisualMode('table')">Tabel</button>
|
||||
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'bars' }" @click="setVisualMode('bars')">Staven</button>
|
||||
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'cards' }" @click="setVisualMode('cards')">Kaarten</button>
|
||||
</div>
|
||||
|
||||
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
|
||||
<div class="rc-style-dots">
|
||||
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
|
||||
<template x-for="s in styleFilters" :key="'d-' + s.id">
|
||||
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
|
||||
|
||||
<div class="rc-style-chips">
|
||||
<button type="button" class="rc-chip" :class="{ active: !filterStyle }" @click="filterStyle = ''">Alle</button>
|
||||
<template x-for="s in styleFilters" :key="'c-' + s.id">
|
||||
<button type="button" class="rc-chip" :class="['rc-chip-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id"
|
||||
x-text="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-wrap">
|
||||
<div class="rc-goals-banner" x-show="goals.vision_text || goals.horizon_text || goals.tagline">
|
||||
<span x-text="goals.vision_text"></span>
|
||||
<span class="rc-goals-sep" x-show="goals.horizon_text">·</span>
|
||||
<span x-text="goals.horizon_text"></span>
|
||||
<span class="rc-goals-sep" x-show="goals.mid_text">·</span>
|
||||
<span x-text="goals.mid_text"></span>
|
||||
<em x-show="goals.tagline" x-text="goals.tagline"></em>
|
||||
</div>
|
||||
|
||||
<!-- TABEL -->
|
||||
<div class="rc-visual-wrap" x-show="visualMode === 'table'" x-cloak>
|
||||
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-deal" />
|
||||
@@ -60,15 +79,6 @@
|
||||
<col class="rc-col-donut" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
|
||||
<!-- Excel rij 1: goals -->
|
||||
<tr class="rc-visual-goals">
|
||||
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
|
||||
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-visual-head">
|
||||
<td class="rc-vcell">Deal</td>
|
||||
<td class="rc-vcell rc-num">Marge mnd</td>
|
||||
@@ -76,84 +86,152 @@
|
||||
<td class="rc-vcell rc-num">Aandeel</td>
|
||||
<td class="rc-vcell">Next steps</td>
|
||||
</tr>
|
||||
|
||||
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
|
||||
<template x-for="row in visualRows()" :key="'t-' + row.source_row + '-' + row.name">
|
||||
<tr class="rc-visual-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), { 'rc-row-open': selectedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
|
||||
<td class="rc-vcell rc-vdeal">
|
||||
<span class="rc-deal-name" x-text="row.name"></span>
|
||||
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
|
||||
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_month">
|
||||
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_year">
|
||||
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdonut">
|
||||
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
|
||||
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vsteps">
|
||||
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
|
||||
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
|
||||
<td class="rc-expand-cell" colspan="5">
|
||||
<div class="rc-expand-inner">
|
||||
<div class="rc-expand-chart">
|
||||
<div class="rc-bar-chart">
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-stats">
|
||||
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
|
||||
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
|
||||
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-body">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
|
||||
<div class="rc-expand-task" x-show="row.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- STAVEN -->
|
||||
<div class="rc-bars-view" x-show="visualMode === 'bars'" x-cloak>
|
||||
<template x-for="row in rankedRows()" :key="'b-' + row.source_row + '-' + row.name">
|
||||
<button type="button" class="rc-bar-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), { active: selectedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
<div class="rc-bar-row-top">
|
||||
<span class="rc-bar-name" x-text="row.name"></span>
|
||||
<span class="rc-bar-amt" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<span class="rc-bar-share" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<div class="rc-bar-track">
|
||||
<div class="rc-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- KAARTEN -->
|
||||
<div class="rc-cards-view" x-show="visualMode === 'cards'" x-cloak>
|
||||
<template x-for="row in cardRows()" :key="'k-' + row.source_row + '-' + row.name">
|
||||
<article class="rc-card"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), { active: selectedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
<div class="rc-card-head">
|
||||
<h3 x-text="row.name"></h3>
|
||||
<span class="rc-chip rc-chip-static" :class="'rc-chip-' + (row.row_style || 'white')" x-text="styleLabel(row.row_style)"></span>
|
||||
</div>
|
||||
<div class="rc-card-metrics">
|
||||
<div>
|
||||
<span>mnd</span>
|
||||
<strong x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></strong>
|
||||
</div>
|
||||
<div x-show="row.margin_year != null">
|
||||
<span>jr</span>
|
||||
<strong x-text="fmtEuroCompact(row.margin_year)"></strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>aandeel</span>
|
||||
<strong x-text="sharePct(row) + '%'"></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-card-bar" aria-hidden="true">
|
||||
<div :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
<p class="rc-card-steps" x-text="truncate(row.next_steps, 100) || 'Geen next step'"></p>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- DETAIL PANEEL -->
|
||||
<aside class="rc-detail" x-show="selectedProject" x-cloak>
|
||||
<div class="rc-detail-head">
|
||||
<div>
|
||||
<h3 x-text="selectedProject?.name"></h3>
|
||||
<span class="rc-chip rc-chip-static" :class="'rc-chip-' + (selectedProject?.row_style || 'white')"
|
||||
x-text="styleLabel(selectedProject?.row_style)"></span>
|
||||
</div>
|
||||
<button type="button" class="rc-detail-close" @click="selectedKey = null; expandedKey = null">✕</button>
|
||||
</div>
|
||||
<div class="rc-detail-grid">
|
||||
<div class="rc-detail-stat">
|
||||
<span>Marge / mnd</span>
|
||||
<strong x-text="fmtEuro(selectedProject?.margin_month)"></strong>
|
||||
</div>
|
||||
<div class="rc-detail-stat" x-show="selectedProject?.margin_year != null">
|
||||
<span>Marge / jr</span>
|
||||
<strong x-text="fmtEuro(selectedProject?.margin_year)"></strong>
|
||||
</div>
|
||||
<div class="rc-detail-stat">
|
||||
<span>Portfolio</span>
|
||||
<strong x-text="sharePct(selectedProject || {}) + '%'"></strong>
|
||||
</div>
|
||||
<div class="rc-detail-ring" :style="donutStyle(selectedProject || {})">
|
||||
<span x-text="sharePct(selectedProject || {}) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-detail-compare">
|
||||
<div class="rc-detail-compare-col">
|
||||
<div class="rc-detail-vbar" :style="'height:' + marginBarPct(selectedProject || {}, 'margin_month') + '%;background:' + styleColor(selectedProject?.row_style)"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-detail-compare-col" x-show="selectedProject?.margin_year != null">
|
||||
<div class="rc-detail-vbar rc-detail-vbar-year" :style="'height:' + marginBarPct(selectedProject || {}, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-detail-status">
|
||||
<span class="rc-detail-label">Zet status</span>
|
||||
<div class="rc-style-chips rc-style-chips-assign">
|
||||
<template x-for="s in styleFilters" :key="'as-' + s.id">
|
||||
<button type="button" class="rc-chip" :class="['rc-chip-' + s.id, { active: selectedProject?.row_style === s.id }]"
|
||||
@click="setCockpitStyle(s.id)" x-text="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="rc-detail-hint" x-show="selectedProject && !selectedProject.db_id">Sync DB om status permanent te bewaren</p>
|
||||
</div>
|
||||
<div class="rc-detail-steps">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="selectedProject?.next_steps || 'Geen next steps in Excel'"></p>
|
||||
</div>
|
||||
<div class="rc-detail-task" x-show="selectedProject?.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Bewerken: originele Excel -->
|
||||
@@ -258,5 +336,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=6"></script>
|
||||
<script src="/static/js/revenue-cockpit.js?v=12"></script>
|
||||
{% endblock %}
|
||||
|
||||
+4
-1
@@ -69,7 +69,10 @@ services:
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
OPENROUTER_MODEL: ${OPENROUTER_MODEL:-deepseek/deepseek-v4-flash}
|
||||
OPENROUTER_BASE_URL: ${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
||||
HERMAN_LLM_BACKEND: ${HERMAN_LLM_BACKEND:-auto}
|
||||
HERMAN_LLM_BACKEND: ${HERMAN_LLM_BACKEND:-hermes}
|
||||
HERMES_API_BASE: ${HERMES_API_BASE:-http://10.4.7.10:8642/v1}
|
||||
HERMES_API_KEY: ${HERMES_API_KEY:-}
|
||||
HERMES_API_MODEL: ${HERMES_API_MODEL:-hermes-agent}
|
||||
HERMES_BUILD_URL: http://10.4.7.27:8798
|
||||
HERMES_PREVIEW_BASE: http://10.4.7.27:8080
|
||||
NAS_CLIENTS_ROOT: /data/nas-clients
|
||||
|
||||
@@ -23,6 +23,8 @@ INCLUDE_KEYWORDS = (
|
||||
EXCLUDE_KEYWORDS = (
|
||||
"voetbal", "sport", "politiek", "verkiezing", "trump", "bbc", "oorlog",
|
||||
"crypto", "bitcoin", "aandelenbeurs", "beurs ", "weerbericht",
|
||||
"mediamarkt", "ceconomy", "elektro", "airco", "smartphone", "gaming",
|
||||
"console", "laptop", "telecom", "provider", "energieprijs",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user