Foodlinkk B2B
- Laden…
diff --git a/cockpit/app/config.py b/cockpit/app/config.py index 96643f8..d8e975a 100644 --- a/cockpit/app/config.py +++ b/cockpit/app/config.py @@ -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") diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py index 038b85f..7f7c8c3 100644 --- a/cockpit/app/services/briefing.py +++ b/cockpit/app/services/briefing.py @@ -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", + "agents", "Herman", "herman", "ok", + f"{len(pending)} relevante beslissingen", ) - for row in pending[:4]: - agent = row.get("agent_key") or "agent" - yield _briefing_step( - "agent_msg", "agent_mesh", agent, "agent", - f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}", - ) - yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…") + 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", + "retail", "Retail", "retail", "ok", + f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · " + f"{len(data.get('milestones_pending') or [])} milestones", ) - if opp: - top = opp[0] - yield _briefing_step( - "agent_msg", "agent_mesh", "retail", "agent", - f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})", - ) - yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…") + 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) - 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", - ) + model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent" 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}) - - # —— Cucina B2C —— - cu_lines: list[str] = [ - f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships", - f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items", - ] - if rev_stats: - cu_lines.insert( - 0, - f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · " - f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · " - f"{rev_stats.get('open_objectives', 0)} open stappen", - ) - cu_projects = (revenue.get("by_brand") or {}).get("cucina") or [] - if cu_projects: - cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4])) - halal_items = (data.get("halal_recommendations") or {}).get("items") or [] - if halal_items: - cu_lines.append("🎯 Halal kansen (live engine):") - for rec in halal_items[:4]: - reason = (rec.get("reasons") or [""])[0] - cu_lines.append( - f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')} — " - f"score {rec.get('score')} · {reason}" - ) - elif opp: - top = opp[0] - cu_lines.append( - f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — " - f"score {round(float(top.get('halal_opportunity_score') or 0))}/100" - ) - ms = data.get("milestones_pending") or [] - if ms: - cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})") - sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines}) - - # —— Vandaag in cockpit —— - cockpit_lines: list[str] = [] - if pending_n: - cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou") - for row in (data.get("pending_approval_requests") or [])[:3]: - cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}") - for line in activity[:5]: - cockpit_lines.append(_short_activity(line)) - if not cockpit_lines: - cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.") - sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "⚡", "lines": cockpit_lines[:6]}) - - # —— Markt & kennis —— - news_lines: list[str] = [] - if market.get("best_performer"): - bp = market["best_performer"] - news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%") - seen_news: set[str] = set() - for row in (data.get("trending_food") or []): - title = str(row.get("title") or "").strip()[:95] - if not title or title in seen_news: + 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 - seen_news.add(title) - news_lines.append(title) - if len(seen_news) >= 2: + fl_lines.append(_revenue_line(p)) + n_rev += 1 + if n_rev >= 2: break - if data.get("nas_docs"): - news_lines.append( - f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}" - ) - if news_lines: - sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]}) + if export.get("crm_linked"): + fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld") - 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}") + # —— Cucina B2C (compact) —— + cu_lines: list[str] = [] + if rev_stats: + 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 [] + 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 + 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 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))})" + ) + + 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 = "" diff --git a/cockpit/app/services/llm_router.py b/cockpit/app/services/llm_router.py index 4f937ee..19b6daf 100644 --- a/cockpit/app/services/llm_router.py +++ b/cockpit/app/services/llm_router.py @@ -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 diff --git a/cockpit/static/css/app-sidebar-neo.css b/cockpit/static/css/app-sidebar-neo.css index 107be6e..59ffb89 100644 --- a/cockpit/static/css/app-sidebar-neo.css +++ b/cockpit/static/css/app-sidebar-neo.css @@ -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; } +} diff --git a/cockpit/static/css/herman-dashboard.css b/cockpit/static/css/herman-dashboard.css index 015efc0..60249d7 100644 --- a/cockpit/static/css/herman-dashboard.css +++ b/cockpit/static/css/herman-dashboard.css @@ -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; +} diff --git a/cockpit/static/css/revenue-cockpit.css b/cockpit/static/css/revenue-cockpit.css index 35184d6..096c4d6 100644 --- a/cockpit/static/css/revenue-cockpit.css +++ b/cockpit/static/css/revenue-cockpit.css @@ -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%; } +} diff --git a/cockpit/static/js/briefing-charts.js b/cockpit/static/js/briefing-charts.js index 3b57f36..e411a4e 100644 --- a/cockpit/static/js/briefing-charts.js +++ b/cockpit/static/js/briefing-charts.js @@ -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 = + '
Revenue Cockpit → · CRM deals →
'; + 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 = + 'Revenue Cockpit → · CRM deals →
'; } 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 = 'Geen KPI\'s geselecteerd — klik Dashboard instellen.
'; + container.innerHTML = 'Geen KPI\'s
'; 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 = '')
+ .replace(/\n/g, '
');
+ sumEl.innerHTML = '
' + html + '
Live samenvatting laden…
'; - } - return; - } - - container.innerHTML = sections.map(function (sec) { - var lines = (sec.lines || []).filter(Boolean); - var lis = lines.length - ? lines.map(function () { return 'Laden…