diff --git a/cockpit/app/routes/api.py b/cockpit/app/routes/api.py index 97f3050..68b6686 100644 --- a/cockpit/app/routes/api.py +++ b/cockpit/app/routes/api.py @@ -68,6 +68,17 @@ async def herman_briefing_stats(): bookmarks = [] stats["rss_bookmarks"] = bookmarks stats["rss_bookmark_ids"] = list(bookmark_map.keys()) + if not stats.get("rss_live") and not stats.get("trending_food"): + try: + stats["rss_live"] = fetch_all( + """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, + f.category, i.published_at + FROM rss_items i + JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE + ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25""" + ) + except Exception: + stats["rss_live"] = [] return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()} diff --git a/cockpit/app/routes/projects_api.py b/cockpit/app/routes/projects_api.py index 3a7e5b2..14ca3e5 100644 --- a/cockpit/app/routes/projects_api.py +++ b/cockpit/app/routes/projects_api.py @@ -52,6 +52,8 @@ class LinkNasFileBody(BaseModel): class PreferencesBody(BaseModel): dashboard_layout: Optional[list[str]] = None + dashboard_widgets: Optional[dict[str, bool]] = None + dashboard_kpis: Optional[list[str]] = None global_viz_mode: Optional[str] = None viz_modes: Optional[dict[str, str]] = None locale: Optional[str] = None @@ -190,6 +192,8 @@ def api_save_ui_preferences(body: PreferencesBody) -> dict[str, Any]: prefs = ui_preferences.save_preferences( "ceo", dashboard_layout=body.dashboard_layout, + dashboard_widgets=body.dashboard_widgets, + dashboard_kpis=body.dashboard_kpis, global_viz_mode=body.global_viz_mode, viz_modes=body.viz_modes, locale=body.locale, diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py index 71df946..e79373a 100644 --- a/cockpit/app/services/briefing.py +++ b/cockpit/app/services/briefing.py @@ -285,6 +285,18 @@ def collect_briefing_data() -> dict[str, Any]: except Exception: data["trending_food"] = [] + if not data.get("trending_food"): + try: + data["trending_food"] = fetch_all( + """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, + f.category, i.published_at + FROM rss_items i + JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE + ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 12""" + ) + except Exception: + data["trending_food"] = [] + try: data["food_market_highlights"] = fetch_all( """SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category @@ -296,6 +308,22 @@ def collect_briefing_data() -> dict[str, Any]: except Exception: data["food_market_highlights"] = [] + if not data.get("food_market_highlights"): + data["food_market_highlights"] = list(data.get("trending_food") or [])[:10] + + try: + data["rss_live"] = fetch_all( + """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, + f.category, i.published_at + FROM rss_items i + JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE + ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25""" + ) + except Exception: + data["rss_live"] = list(data.get("trending_food") or []) + + data["rss_items"] = _safe_count("rss_items") + data["activity_log"] = _build_activity_log(data) return data diff --git a/cockpit/app/services/ui_preferences.py b/cockpit/app/services/ui_preferences.py index a8078de..efd7340 100644 --- a/cockpit/app/services/ui_preferences.py +++ b/cockpit/app/services/ui_preferences.py @@ -1,14 +1,62 @@ -"""User UI preferences โ dashboard layout and visualization modes.""" +"""User UI preferences โ dashboard layout, widgets, KPIs, visualization modes.""" from __future__ import annotations import json from typing import Any -from app.db import execute, fetch_one +from app.db import fetch_one -DEFAULT_LAYOUT = ["kpis", "executive", "briefing", "retail", "analytics", "approvals", "feed"] +DEFAULT_LAYOUT = [ + "kpis", + "executive", + "briefing", + "rss", + "retail", + "analytics", + "approvals", + "feed", +] DEFAULT_VIZ = "neo-bars" +DEFAULT_KPI_LAYOUT = [ + "pipeline", + "clients_active", + "crm_partnerships", + "supermarkets", + "trending_food", + "pending_approvals", +] + +WIDGET_CATALOG = [ + {"id": "kpis", "label": "CEO KPI's", "icon": "๐"}, + {"id": "executive", "label": "Alles op een rij", "icon": "๐"}, + {"id": "briefing", "label": "Herman briefing", "icon": "๐"}, + {"id": "rss", "label": "RSS live feed", "icon": "๐ฐ"}, + {"id": "retail", "label": "Retail operatie", "icon": "๐ช"}, + {"id": "analytics", "label": "Data & analytics", "icon": "๐"}, + {"id": "approvals", "label": "Agent goedkeuringen", "icon": "โ"}, + {"id": "feed", "label": "Agent feed & projecten", "icon": "โก"}, +] + +KPI_CATALOG = [ + {"id": "pipeline", "label": "Pipeline", "sub": "actieve deals", "icon": "๐ฐ"}, + {"id": "clients_active", "label": "Actieve klanten", "sub": "CRM ยท zaken mee", "icon": "๐ค"}, + {"id": "clients_total", "label": "Totaal klanten", "sub": "CRM database", "icon": "๐ฅ"}, + {"id": "crm_partnerships", "label": "CRM partnerships", "sub": "actieve filialen", "icon": "๐ช"}, + {"id": "supermarkets", "label": "Supermarkten", "sub": "Retail 360 DB", "icon": "๐"}, + {"id": "wholesalers", "label": "Groothandels", "sub": "Retail 360 DB", "icon": "๐ฆ"}, + {"id": "trending_food", "label": "Food trends", "sub": "RSS live", "icon": "๐ฐ"}, + {"id": "rss_items", "label": "RSS items", "sub": "totaal in DB", "icon": "๐ก"}, + {"id": "rss_bookmarks", "label": "RSS bookmarks", "sub": "opgeslagen", "icon": "โ "}, + {"id": "pending_approvals", "label": "Goedkeuringen", "sub": "wacht op OK", "icon": "โ"}, + {"id": "deals", "label": "Deals", "sub": "totaal CRM", "icon": "๐ผ"}, + {"id": "products", "label": "Producten", "sub": "catalogus", "icon": "๐ฅซ"}, + {"id": "suppliers", "label": "Leveranciers", "sub": "sourcing", "icon": "๐"}, + {"id": "nas_docs", "label": "NAS documenten", "sub": "geanalyseerd", "icon": "๐"}, + {"id": "promo_campaigns", "label": "Promo campagnes", "sub": "actief", "icon": "๐"}, + {"id": "market_best", "label": "Aandeel top", "sub": "retail markt", "icon": "๐"}, +] + VIZ_MODES = [ {"id": "neo-bars", "label": "Neo staafdiagram", "icon": "๐"}, {"id": "neo-rings", "label": "Neo ringen", "icon": "๐ฉ"}, @@ -17,49 +65,108 @@ VIZ_MODES = [ {"id": "neo-table", "label": "Neo tabel", "icon": "๐"}, ] +_ALL_WIDGET_IDS = {w["id"] for w in WIDGET_CATALOG} +_ALL_KPI_IDS = {k["id"] for k in KPI_CATALOG} + + +def _default_widgets() -> dict[str, bool]: + return {w["id"]: True for w in WIDGET_CATALOG} + + +def _parse_json(val: Any, fallback: Any) -> Any: + if val is None: + return fallback + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return fallback + return val + + +def _normalize_layout(layout: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for wid in layout: + if wid in _ALL_WIDGET_IDS and wid not in seen: + out.append(wid) + seen.add(wid) + for w in WIDGET_CATALOG: + if w["id"] not in seen: + out.append(w["id"]) + return out + + +def _normalize_widgets(widgets: dict[str, Any] | None) -> dict[str, bool]: + base = _default_widgets() + if not widgets: + return base + for wid in _ALL_WIDGET_IDS: + if wid in widgets: + base[wid] = bool(widgets[wid]) + return base + + +def _normalize_kpis(kpis: list[str] | None) -> list[str]: + if not kpis: + return list(DEFAULT_KPI_LAYOUT) + seen: set[str] = set() + out: list[str] = [] + for kid in kpis: + if kid in _ALL_KPI_IDS and kid not in seen: + out.append(kid) + seen.add(kid) + return out or list(DEFAULT_KPI_LAYOUT) + def get_preferences(user_key: str = "ceo") -> dict[str, Any]: row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,)) if not row: return { "user_key": user_key, - "dashboard_layout": DEFAULT_LAYOUT, + "dashboard_layout": list(DEFAULT_LAYOUT), + "dashboard_widgets": _default_widgets(), + "dashboard_kpis": list(DEFAULT_KPI_LAYOUT), "viz_modes": {}, "global_viz_mode": DEFAULT_VIZ, "locale": "nl", "viz_options": VIZ_MODES, + "widget_catalog": WIDGET_CATALOG, + "kpi_catalog": KPI_CATALOG, } - layout = row.get("dashboard_layout") or DEFAULT_LAYOUT - if isinstance(layout, str): - try: - layout = json.loads(layout) - except Exception: - layout = DEFAULT_LAYOUT - viz_modes = row.get("viz_modes") or {} - if isinstance(viz_modes, str): - try: - viz_modes = json.loads(viz_modes) - except Exception: - viz_modes = {} + layout = _normalize_layout(_parse_json(row.get("dashboard_layout"), DEFAULT_LAYOUT)) + widgets = _normalize_widgets(_parse_json(row.get("dashboard_widgets"), None)) + kpis = _normalize_kpis(_parse_json(row.get("dashboard_kpis"), None)) + viz_modes = _parse_json(row.get("viz_modes"), {}) return { "user_key": user_key, "dashboard_layout": layout, - "viz_modes": viz_modes, + "dashboard_widgets": widgets, + "dashboard_kpis": kpis, + "viz_modes": viz_modes if isinstance(viz_modes, dict) else {}, "global_viz_mode": row.get("global_viz_mode") or DEFAULT_VIZ, "locale": row.get("locale") or "nl", "viz_options": VIZ_MODES, + "widget_catalog": WIDGET_CATALOG, + "kpi_catalog": KPI_CATALOG, } def save_preferences( user_key: str, dashboard_layout: list[str] | None = None, + dashboard_widgets: dict[str, bool] | None = None, + dashboard_kpis: list[str] | None = None, global_viz_mode: str | None = None, viz_modes: dict[str, str] | None = None, locale: str | None = None, ) -> dict[str, Any]: current = get_preferences(user_key) - layout = dashboard_layout if dashboard_layout is not None else current["dashboard_layout"] + layout = _normalize_layout(dashboard_layout if dashboard_layout is not None else current["dashboard_layout"]) + widgets = _normalize_widgets( + dashboard_widgets if dashboard_widgets is not None else current["dashboard_widgets"] + ) + kpis = _normalize_kpis(dashboard_kpis if dashboard_kpis is not None else current["dashboard_kpis"]) gviz = global_viz_mode if global_viz_mode is not None else current["global_viz_mode"] vmodes = viz_modes if viz_modes is not None else current["viz_modes"] loc = locale if locale is not None else current.get("locale", "nl") @@ -67,16 +174,29 @@ def save_preferences( loc = "nl" fetch_one( """ - INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode, viz_modes, locale, updated_at) - VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, NOW()) + INSERT INTO user_ui_preferences ( + user_key, dashboard_layout, dashboard_widgets, dashboard_kpis, + global_viz_mode, viz_modes, locale, updated_at + ) + VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, %s, %s::jsonb, %s, NOW()) ON CONFLICT (user_key) DO UPDATE SET dashboard_layout = EXCLUDED.dashboard_layout, + dashboard_widgets = EXCLUDED.dashboard_widgets, + dashboard_kpis = EXCLUDED.dashboard_kpis, global_viz_mode = EXCLUDED.global_viz_mode, viz_modes = EXCLUDED.viz_modes, locale = EXCLUDED.locale, updated_at = NOW() RETURNING user_key """, - (user_key, json.dumps(layout), gviz, json.dumps(vmodes), loc), + ( + user_key, + json.dumps(layout), + json.dumps(widgets), + json.dumps(kpis), + gviz, + json.dumps(vmodes), + loc, + ), ) return get_preferences(user_key) diff --git a/cockpit/static/css/herman-dashboard.css b/cockpit/static/css/herman-dashboard.css index f803724..42bfc9b 100644 --- a/cockpit/static/css/herman-dashboard.css +++ b/cockpit/static/css/herman-dashboard.css @@ -289,7 +289,65 @@ font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: #64748b; margin-bottom: 0.35rem; user-select: none; } -.hm-widget-handle::before { content: 'โฎโฎ '; color: var(--hm-cyan); } +.hm-widget-handle::before { content: ''; } +.hm-widget-hidden { display: none !important; } +.hm-dash-editing [data-widget]:not(.hm-widget-hidden) { + outline: 1px dashed rgba(0,229,255,0.25); + border-radius: 12px; + padding: 0.5rem; + margin: -0.5rem; +} +.hm-widget-hint { font-size: 0.7rem; color: #64748b; font-weight: normal; } + +.hm-customize-wrap { + margin-bottom: 1rem; + padding: 1rem 1.1rem; + background: rgba(13,18,25,0.92); + border: 1px solid var(--hm-cyan); + border-radius: 14px; + box-shadow: 0 0 24px rgba(0,229,255,0.08); +} +.hm-customize-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; } +@media (max-width: 900px) { .hm-customize-panel { grid-template-columns: 1fr; } } +.hm-customize-col h4 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #e2e8f0; } +.hm-customize-hint { margin: 0 0 0.65rem; font-size: 0.72rem; color: #64748b; } +.hm-customize-list { display: flex; flex-direction: column; gap: 0.35rem; } +.hm-customize-item { + display: flex; align-items: center; gap: 0.5rem; + font-size: 0.8rem; color: #cbd5e1; cursor: pointer; + padding: 0.35rem 0.5rem; border-radius: 8px; + background: rgba(0,0,0,0.2); border: 1px solid rgba(148,163,184,0.12); +} +.hm-customize-item:hover { border-color: rgba(0,229,255,0.35); } +.hm-kpi-picker { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.hm-kpi-chip { + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.72rem; color: #94a3b8; cursor: grab; + padding: 0.3rem 0.55rem; border-radius: 999px; + border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25); + user-select: none; +} +.hm-kpi-chip.active { border-color: var(--hm-cyan); color: var(--hm-cyan); } +.hm-kpi-chip.hm-kpi-dragging { opacity: 0.6; } +.hm-kpi-chip input { accent-color: var(--hm-cyan); } + +.hm-rss-panel { max-height: 420px; overflow-y: auto; } +.hm-rss-head { font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.65rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } +.hm-rss-list { display: flex; flex-direction: column; gap: 0.45rem; } +.hm-rss-item { + display: flex; gap: 0.65rem; align-items: flex-start; + padding: 0.55rem 0.65rem; border-radius: 10px; + border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.2); + text-decoration: none; color: inherit; transition: border-color 0.15s; +} +.hm-rss-item:hover { border-color: rgba(0,229,255,0.4); } +.hm-rss-cat { + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.05em; + color: var(--hm-cyan); background: rgba(0,229,255,0.1); + padding: 0.2rem 0.4rem; border-radius: 6px; white-space: nowrap; +} +.hm-rss-body strong { display: block; font-size: 0.82rem; color: #e2e8f0; margin-bottom: 0.15rem; } +.hm-rss-body small { font-size: 0.68rem; color: #64748b; } .viz-toolbar { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; diff --git a/cockpit/static/js/briefing-charts.js b/cockpit/static/js/briefing-charts.js index ed757a9..6bf06cb 100644 --- a/cockpit/static/js/briefing-charts.js +++ b/cockpit/static/js/briefing-charts.js @@ -232,19 +232,65 @@ window.BriefingCharts = (function () { }); } - function renderKpis(container, stats) { - if (!container) return; + function kpiValue(id, stats) { var summary = stats.market_summary || {}; - var avgPct = Number(summary.avg_change_pct || 0); var best = summary.best_performer || {}; - var items = [ - { label: 'Pipeline', sub: 'actieve deals', icon: '๐ฐ', color: '#00e5ff', pct: '72%', val: 'โฌ' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL') }, - { label: 'Actieve klanten', sub: 'CRM ยท zaken mee', icon: '๐ค', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' }, - { label: 'CRM partnerships', sub: 'actieve filialen', icon: '๐ช', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' }, - { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '๐ช', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') }, - { label: 'Food trends', sub: 'RSS live', icon: '๐ฐ', color: '#38bdf8', pct: Math.min(95, ((stats.trending_food || stats.food_market_highlights || []).length * 10)) + '%', val: (stats.trending_food || stats.food_market_highlights || []).length || 0, link: '/marketing' }, - { label: 'Goedkeuringen', sub: 'wacht op OK', icon: 'โ', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 }, - ]; + var trendCount = (stats.trending_food || stats.food_market_highlights || []).length; + switch (id) { + case 'pipeline': return 'โฌ' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'); + case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0); + case 'clients_total': return String(stats.clients_total || stats.clients || 0); + case 'crm_partnerships': return String(stats.crm_partnerships || 0); + case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL'); + case 'wholesalers': return String(stats.wholesalers || 0); + case 'trending_food': return String(trendCount || 0); + case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0); + case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0); + case 'pending_approvals': return String(stats.pending_approvals || 0); + case 'deals': return String(stats.deals || 0); + case 'products': return String(stats.products || 0); + case 'suppliers': return String(stats.suppliers || 0); + case 'nas_docs': return String(stats.nas_docs || 0); + case 'promo_campaigns': return String(stats.promo_campaigns || 0); + case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : 'โ'; + default: return 'โ'; + } + } + + function kpiPct(id, stats) { + var summary = stats.market_summary || {}; + var trendCount = (stats.trending_food || stats.food_market_highlights || []).length; + switch (id) { + case 'pipeline': return '72%'; + case 'clients_active': + return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%'; + case 'crm_partnerships': return '45%'; + case 'supermarkets': return '88%'; + case 'trending_food': return Math.min(95, trendCount * 10) + '%'; + case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%'; + case 'pending_approvals': return '30%'; + case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%'; + default: return '50%'; + } + } + + function renderKpis(container, stats, selectedKpis) { + if (!container) return; + var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {}; + var order = selectedKpis || window._dashboardKpis || + (window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) || + ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals']; + var items = order.map(function (id) { + var m = meta[id] || { label: id, sub: '', icon: 'โช', color: '#94a3b8' }; + return { + label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8', + pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link, + }; + }); + if (!items.length) { + container.innerHTML = '
Geen KPI\'s geselecteerd โ klik Dashboard instellen.
'; + return; + } container.className = 'hm-neo-kpi-row'; container.innerHTML = items.map(function (it) { var inner = 'Geen RSS items โ klik RSS ophalen of ga naar Marketing Hub.
'; + var btn = container.querySelector('#btn-rss-refresh-dash'); + if (btn) btn.addEventListener('click', function () { + if (window._refreshRssDash) window._refreshRssDash(); + }); + return; + } + container.innerHTML = head + 'Sleep widgets om te ordenen. Vink uit om te verbergen.
' + + 'Kies welke KPI-kaarten bovenaan staan. Sleep om volgorde te wijzigen.
' + + '