SysOps: deploy-all — 2026-06-09 10:59 UTC
This commit is contained in:
@@ -68,6 +68,17 @@ async def herman_briefing_stats():
|
|||||||
bookmarks = []
|
bookmarks = []
|
||||||
stats["rss_bookmarks"] = bookmarks
|
stats["rss_bookmarks"] = bookmarks
|
||||||
stats["rss_bookmark_ids"] = list(bookmark_map.keys())
|
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()}
|
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class LinkNasFileBody(BaseModel):
|
|||||||
|
|
||||||
class PreferencesBody(BaseModel):
|
class PreferencesBody(BaseModel):
|
||||||
dashboard_layout: Optional[list[str]] = None
|
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
|
global_viz_mode: Optional[str] = None
|
||||||
viz_modes: Optional[dict[str, str]] = None
|
viz_modes: Optional[dict[str, str]] = None
|
||||||
locale: Optional[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(
|
prefs = ui_preferences.save_preferences(
|
||||||
"ceo",
|
"ceo",
|
||||||
dashboard_layout=body.dashboard_layout,
|
dashboard_layout=body.dashboard_layout,
|
||||||
|
dashboard_widgets=body.dashboard_widgets,
|
||||||
|
dashboard_kpis=body.dashboard_kpis,
|
||||||
global_viz_mode=body.global_viz_mode,
|
global_viz_mode=body.global_viz_mode,
|
||||||
viz_modes=body.viz_modes,
|
viz_modes=body.viz_modes,
|
||||||
locale=body.locale,
|
locale=body.locale,
|
||||||
|
|||||||
@@ -285,6 +285,18 @@ def collect_briefing_data() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
data["trending_food"] = []
|
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:
|
try:
|
||||||
data["food_market_highlights"] = fetch_all(
|
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
|
"""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:
|
except Exception:
|
||||||
data["food_market_highlights"] = []
|
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)
|
data["activity_log"] = _build_activity_log(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
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_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 = [
|
VIZ_MODES = [
|
||||||
{"id": "neo-bars", "label": "Neo staafdiagram", "icon": "📊"},
|
{"id": "neo-bars", "label": "Neo staafdiagram", "icon": "📊"},
|
||||||
{"id": "neo-rings", "label": "Neo ringen", "icon": "🍩"},
|
{"id": "neo-rings", "label": "Neo ringen", "icon": "🍩"},
|
||||||
@@ -17,49 +65,108 @@ VIZ_MODES = [
|
|||||||
{"id": "neo-table", "label": "Neo tabel", "icon": "📋"},
|
{"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]:
|
def get_preferences(user_key: str = "ceo") -> dict[str, Any]:
|
||||||
row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,))
|
row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,))
|
||||||
if not row:
|
if not row:
|
||||||
return {
|
return {
|
||||||
"user_key": user_key,
|
"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": {},
|
"viz_modes": {},
|
||||||
"global_viz_mode": DEFAULT_VIZ,
|
"global_viz_mode": DEFAULT_VIZ,
|
||||||
"locale": "nl",
|
"locale": "nl",
|
||||||
"viz_options": VIZ_MODES,
|
"viz_options": VIZ_MODES,
|
||||||
|
"widget_catalog": WIDGET_CATALOG,
|
||||||
|
"kpi_catalog": KPI_CATALOG,
|
||||||
}
|
}
|
||||||
layout = row.get("dashboard_layout") or DEFAULT_LAYOUT
|
layout = _normalize_layout(_parse_json(row.get("dashboard_layout"), DEFAULT_LAYOUT))
|
||||||
if isinstance(layout, str):
|
widgets = _normalize_widgets(_parse_json(row.get("dashboard_widgets"), None))
|
||||||
try:
|
kpis = _normalize_kpis(_parse_json(row.get("dashboard_kpis"), None))
|
||||||
layout = json.loads(layout)
|
viz_modes = _parse_json(row.get("viz_modes"), {})
|
||||||
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 = {}
|
|
||||||
return {
|
return {
|
||||||
"user_key": user_key,
|
"user_key": user_key,
|
||||||
"dashboard_layout": layout,
|
"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,
|
"global_viz_mode": row.get("global_viz_mode") or DEFAULT_VIZ,
|
||||||
"locale": row.get("locale") or "nl",
|
"locale": row.get("locale") or "nl",
|
||||||
"viz_options": VIZ_MODES,
|
"viz_options": VIZ_MODES,
|
||||||
|
"widget_catalog": WIDGET_CATALOG,
|
||||||
|
"kpi_catalog": KPI_CATALOG,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_preferences(
|
def save_preferences(
|
||||||
user_key: str,
|
user_key: str,
|
||||||
dashboard_layout: list[str] | None = None,
|
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,
|
global_viz_mode: str | None = None,
|
||||||
viz_modes: dict[str, str] | None = None,
|
viz_modes: dict[str, str] | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
current = get_preferences(user_key)
|
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"]
|
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"]
|
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")
|
loc = locale if locale is not None else current.get("locale", "nl")
|
||||||
@@ -67,16 +174,29 @@ def save_preferences(
|
|||||||
loc = "nl"
|
loc = "nl"
|
||||||
fetch_one(
|
fetch_one(
|
||||||
"""
|
"""
|
||||||
INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode, viz_modes, locale, updated_at)
|
INSERT INTO user_ui_preferences (
|
||||||
VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, NOW())
|
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
|
ON CONFLICT (user_key) DO UPDATE SET
|
||||||
dashboard_layout = EXCLUDED.dashboard_layout,
|
dashboard_layout = EXCLUDED.dashboard_layout,
|
||||||
|
dashboard_widgets = EXCLUDED.dashboard_widgets,
|
||||||
|
dashboard_kpis = EXCLUDED.dashboard_kpis,
|
||||||
global_viz_mode = EXCLUDED.global_viz_mode,
|
global_viz_mode = EXCLUDED.global_viz_mode,
|
||||||
viz_modes = EXCLUDED.viz_modes,
|
viz_modes = EXCLUDED.viz_modes,
|
||||||
locale = EXCLUDED.locale,
|
locale = EXCLUDED.locale,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING user_key
|
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)
|
return get_preferences(user_key)
|
||||||
|
|||||||
@@ -289,7 +289,65 @@
|
|||||||
font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase;
|
font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
color: #64748b; margin-bottom: 0.35rem; user-select: none;
|
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 {
|
.viz-toolbar {
|
||||||
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
|
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
|
||||||
|
|||||||
@@ -232,19 +232,65 @@ window.BriefingCharts = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKpis(container, stats) {
|
function kpiValue(id, stats) {
|
||||||
if (!container) return;
|
|
||||||
var summary = stats.market_summary || {};
|
var summary = stats.market_summary || {};
|
||||||
var avgPct = Number(summary.avg_change_pct || 0);
|
|
||||||
var best = summary.best_performer || {};
|
var best = summary.best_performer || {};
|
||||||
var items = [
|
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
|
||||||
{ label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', pct: '72%', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL') },
|
switch (id) {
|
||||||
{ 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' },
|
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
|
||||||
{ label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' },
|
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
|
||||||
{ label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') },
|
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
|
||||||
{ 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' },
|
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
|
||||||
{ label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
|
case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
|
||||||
];
|
case 'wholesalers': return String(stats.wholesalers || 0);
|
||||||
|
case 'trending_food': return String(trendCount || 0);
|
||||||
|
case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
|
||||||
|
case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
|
||||||
|
case 'pending_approvals': return String(stats.pending_approvals || 0);
|
||||||
|
case 'deals': return String(stats.deals || 0);
|
||||||
|
case 'products': return String(stats.products || 0);
|
||||||
|
case 'suppliers': return String(stats.suppliers || 0);
|
||||||
|
case 'nas_docs': return String(stats.nas_docs || 0);
|
||||||
|
case 'promo_campaigns': return String(stats.promo_campaigns || 0);
|
||||||
|
case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
|
||||||
|
default: return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiPct(id, stats) {
|
||||||
|
var summary = stats.market_summary || {};
|
||||||
|
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
|
||||||
|
switch (id) {
|
||||||
|
case 'pipeline': return '72%';
|
||||||
|
case 'clients_active':
|
||||||
|
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
|
||||||
|
case 'crm_partnerships': return '45%';
|
||||||
|
case 'supermarkets': return '88%';
|
||||||
|
case 'trending_food': return Math.min(95, trendCount * 10) + '%';
|
||||||
|
case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
|
||||||
|
case 'pending_approvals': return '30%';
|
||||||
|
case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
|
||||||
|
default: return '50%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpis(container, stats, selectedKpis) {
|
||||||
|
if (!container) return;
|
||||||
|
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
|
||||||
|
var order = selectedKpis || window._dashboardKpis ||
|
||||||
|
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
|
||||||
|
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
|
||||||
|
var items = order.map(function (id) {
|
||||||
|
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
|
||||||
|
return {
|
||||||
|
label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
|
||||||
|
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
container.className = 'hm-neo-kpi-row';
|
container.className = 'hm-neo-kpi-row';
|
||||||
container.innerHTML = items.map(function (it) {
|
container.innerHTML = items.map(function (it) {
|
||||||
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
|
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
|
||||||
@@ -255,6 +301,35 @@ window.BriefingCharts = (function () {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRssFeed(container, stats) {
|
||||||
|
if (!container) return;
|
||||||
|
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
|
||||||
|
var count = stats.rss_items || items.length;
|
||||||
|
var head = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
|
||||||
|
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
|
||||||
|
' · <a href="/marketing">Marketing Hub →</a></div>';
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
|
||||||
|
var btn = container.querySelector('#btn-rss-refresh-dash');
|
||||||
|
if (btn) btn.addEventListener('click', function () {
|
||||||
|
if (window._refreshRssDash) window._refreshRssDash();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
|
||||||
|
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
|
||||||
|
var cat = (r.category || 'feed').toUpperCase();
|
||||||
|
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
|
||||||
|
'<span class="hm-rss-cat">' + cat + '</span>' +
|
||||||
|
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
|
||||||
|
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener('click', function () {
|
||||||
|
if (window._refreshRssDash) window._refreshRssDash();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderRetail(container, stats) {
|
function renderRetail(container, stats) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
|
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
|
||||||
@@ -343,10 +418,12 @@ window.BriefingCharts = (function () {
|
|||||||
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
|
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLive(root, stats, vizMode) {
|
function renderLive(root, stats, vizMode, kpiSelection) {
|
||||||
if (!root || !stats) return;
|
if (!root || !stats) return;
|
||||||
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
|
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
|
||||||
renderKpis(document.getElementById('briefing-kpis'), stats);
|
var kpis = kpiSelection || window._dashboardKpis;
|
||||||
|
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
|
||||||
|
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
|
||||||
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
|
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
|
||||||
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
|
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
|
||||||
renderRetail(document.getElementById('briefing-retail'), stats);
|
renderRetail(document.getElementById('briefing-retail'), stats);
|
||||||
@@ -373,5 +450,9 @@ window.BriefingCharts = (function () {
|
|||||||
renderText(root, content, createdAt);
|
renderText(root, content, createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { render: render, renderLive: renderLive, renderText: renderText, renderExecutiveSummary: renderExecutiveSummary, destroyAll: destroyAll };
|
return {
|
||||||
|
render: render, renderLive: renderLive, renderText: renderText,
|
||||||
|
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
|
||||||
|
renderKpis: renderKpis, destroyAll: destroyAll,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -3,32 +3,81 @@
|
|||||||
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
||||||
executive: { title: 'Alles op een rij', icon: '📋' },
|
executive: { title: 'Alles op een rij', icon: '📋' },
|
||||||
briefing: { title: 'Herman briefing', icon: '📝' },
|
briefing: { title: 'Herman briefing', icon: '📝' },
|
||||||
|
rss: { title: 'RSS live feed', icon: '📰' },
|
||||||
retail: { title: 'Retail operatie', icon: '🏪' },
|
retail: { title: 'Retail operatie', icon: '🏪' },
|
||||||
analytics: { title: 'Data & analytics', icon: '📈' },
|
analytics: { title: 'Data & analytics', icon: '📈' },
|
||||||
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
||||||
feed: { title: 'Agent feed', icon: '⚡' },
|
feed: { title: 'Agent feed', icon: '⚡' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var KPI_META = {
|
||||||
|
pipeline: { label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', link: '/deals' },
|
||||||
|
clients_active: { label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', link: '/clients' },
|
||||||
|
clients_total: { label: 'Totaal klanten', sub: 'CRM database', icon: '👥', color: '#4ade80', link: '/clients' },
|
||||||
|
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
|
||||||
|
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
|
||||||
|
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
|
||||||
|
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
|
||||||
|
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
|
||||||
|
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
|
||||||
|
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
|
||||||
|
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
|
||||||
|
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
|
||||||
|
suppliers: { label: 'Leveranciers', sub: 'sourcing', icon: '🚚', color: '#94a3b8', link: '/suppliers' },
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
var DEFAULT_LAYOUT = Object.keys(WIDGET_META);
|
||||||
|
var DEFAULT_KPIS = ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
|
||||||
|
|
||||||
function loadPrefs() {
|
function loadPrefs() {
|
||||||
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
|
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
|
||||||
return { dashboard_layout: Object.keys(WIDGET_META), global_viz_mode: 'neo-bars', viz_modes: {} };
|
return {
|
||||||
|
dashboard_layout: DEFAULT_LAYOUT.slice(),
|
||||||
|
dashboard_widgets: defaultWidgets(),
|
||||||
|
dashboard_kpis: DEFAULT_KPIS.slice(),
|
||||||
|
global_viz_mode: 'neo-bars',
|
||||||
|
viz_modes: {},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultWidgets() {
|
||||||
|
var m = {};
|
||||||
|
DEFAULT_LAYOUT.forEach(function (id) { m[id] = true; });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(patch) {
|
||||||
|
return fetch('/api/preferences/ui', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}).then(function (r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
function saveLayout(order) {
|
function saveLayout(order) {
|
||||||
return fetch('/api/preferences/ui', {
|
return savePrefs({ dashboard_layout: order });
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ dashboard_layout: order }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveViz(globalMode, vizModes) {
|
function saveViz(globalMode, vizModes) {
|
||||||
return fetch('/api/preferences/ui', {
|
return savePrefs({ global_viz_mode: globalMode, viz_modes: vizModes || {} });
|
||||||
method: 'PUT',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ global_viz_mode: globalMode, viz_modes: vizModes || {} }),
|
function saveDashboardConfig(layout, widgets, kpis) {
|
||||||
});
|
var patch = {};
|
||||||
|
if (layout) patch.dashboard_layout = layout;
|
||||||
|
if (widgets) patch.dashboard_widgets = widgets;
|
||||||
|
if (kpis) patch.dashboard_kpis = kpis;
|
||||||
|
return savePrefs(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
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; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOrder(container, order) {
|
function applyOrder(container, order) {
|
||||||
@@ -42,33 +91,67 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initDrag(container, onReorder) {
|
function applyVisibility(container, widgets) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
var dragEl = null;
|
var wmap = widgets || defaultWidgets();
|
||||||
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
|
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
|
||||||
el.setAttribute('draggable', 'true');
|
var id = el.dataset.widget;
|
||||||
el.classList.add('hm-widget-draggable');
|
if (wmap[id] === false) el.classList.add('hm-widget-hidden');
|
||||||
el.addEventListener('dragstart', function (ev) {
|
else el.classList.remove('hm-widget-hidden');
|
||||||
dragEl = el;
|
|
||||||
el.classList.add('hm-widget-dragging');
|
|
||||||
ev.dataTransfer.effectAllowed = 'move';
|
|
||||||
});
|
|
||||||
el.addEventListener('dragend', function () {
|
|
||||||
el.classList.remove('hm-widget-dragging');
|
|
||||||
dragEl = null;
|
|
||||||
var order = Array.from(container.querySelectorAll('[data-widget]')).map(function (n) { return n.dataset.widget; });
|
|
||||||
if (onReorder) onReorder(order);
|
|
||||||
});
|
|
||||||
el.addEventListener('dragover', function (ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!dragEl || dragEl === el) return;
|
|
||||||
var rect = el.getBoundingClientRect();
|
|
||||||
var after = ev.clientY > rect.top + rect.height / 2;
|
|
||||||
if (after) el.after(dragEl); else el.before(dragEl);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initDrag(container, onReorder, editMode) {
|
||||||
|
if (!container) return function () {};
|
||||||
|
var dragEl = null;
|
||||||
|
function setDraggable(on) {
|
||||||
|
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
|
||||||
|
if (el.classList.contains('hm-widget-hidden')) {
|
||||||
|
el.removeAttribute('draggable');
|
||||||
|
el.classList.remove('hm-widget-draggable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.setAttribute('draggable', on ? 'true' : 'false');
|
||||||
|
el.classList.toggle('hm-widget-draggable', on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bind() {
|
||||||
|
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
|
||||||
|
el.removeEventListener('dragstart', el._hmDragStart);
|
||||||
|
el.removeEventListener('dragend', el._hmDragEnd);
|
||||||
|
el.removeEventListener('dragover', el._hmDragOver);
|
||||||
|
if (el.classList.contains('hm-widget-hidden')) return;
|
||||||
|
el._hmDragStart = function (ev) {
|
||||||
|
dragEl = el;
|
||||||
|
el.classList.add('hm-widget-dragging');
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
el._hmDragEnd = function () {
|
||||||
|
el.classList.remove('hm-widget-dragging');
|
||||||
|
dragEl = null;
|
||||||
|
var order = Array.from(container.querySelectorAll('[data-widget]:not(.hm-widget-hidden)'))
|
||||||
|
.map(function (n) { return n.dataset.widget; });
|
||||||
|
var hidden = Array.from(container.querySelectorAll('[data-widget].hm-widget-hidden'))
|
||||||
|
.map(function (n) { return n.dataset.widget; });
|
||||||
|
if (onReorder) onReorder(order.concat(hidden));
|
||||||
|
};
|
||||||
|
el._hmDragOver = function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!dragEl || dragEl === el) return;
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var after = ev.clientY > rect.top + rect.height / 2;
|
||||||
|
if (after) el.after(dragEl); else el.before(dragEl);
|
||||||
|
};
|
||||||
|
el.addEventListener('dragstart', el._hmDragStart);
|
||||||
|
el.addEventListener('dragend', el._hmDragEnd);
|
||||||
|
el.addEventListener('dragover', el._hmDragOver);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDraggable(!!editMode);
|
||||||
|
bind();
|
||||||
|
return function (on) { setDraggable(on); bind(); };
|
||||||
|
}
|
||||||
|
|
||||||
function buildVizSelector(container, prefs, onChange) {
|
function buildVizSelector(container, prefs, onChange) {
|
||||||
if (!container || !global.VizEngine) return;
|
if (!container || !global.VizEngine) return;
|
||||||
container.innerHTML = '<label class="viz-global-label">Visualisatie:</label>' +
|
container.innerHTML = '<label class="viz-global-label">Visualisatie:</label>' +
|
||||||
@@ -85,13 +168,106 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCustomizePanel(container, prefs, onChange) {
|
||||||
|
if (!container) return;
|
||||||
|
var widgets = prefs.dashboard_widgets || defaultWidgets();
|
||||||
|
var kpis = prefs.dashboard_kpis || DEFAULT_KPIS.slice();
|
||||||
|
var catalog = prefs.widget_catalog || Object.keys(WIDGET_META).map(function (id) {
|
||||||
|
return { id: id, label: (WIDGET_META[id] || {}).title || id, icon: (WIDGET_META[id] || {}).icon || '▪' };
|
||||||
|
});
|
||||||
|
var kpiCatalog = prefs.kpi_catalog || Object.keys(KPI_META).map(function (id) {
|
||||||
|
var m = KPI_META[id];
|
||||||
|
return { id: id, label: m.label, icon: m.icon };
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="hm-customize-panel">' +
|
||||||
|
'<div class="hm-customize-col">' +
|
||||||
|
'<h4>Widgets tonen/verbergen</h4>' +
|
||||||
|
'<p class="hm-customize-hint">Sleep widgets om te ordenen. Vink uit om te verbergen.</p>' +
|
||||||
|
'<div class="hm-customize-list" id="hm-widget-toggles">' +
|
||||||
|
catalog.map(function (w) {
|
||||||
|
var checked = widgets[w.id] !== false ? ' checked' : '';
|
||||||
|
return '<label class="hm-customize-item"><input type="checkbox" data-widget-toggle="' + w.id + '"' + checked + '> ' +
|
||||||
|
(w.icon || '') + ' ' + (w.label || w.id) + '</label>';
|
||||||
|
}).join('') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="hm-customize-col">' +
|
||||||
|
'<h4>KPI\'s selecteren</h4>' +
|
||||||
|
'<p class="hm-customize-hint">Kies welke KPI-kaarten bovenaan staan. Sleep om volgorde te wijzigen.</p>' +
|
||||||
|
'<div class="hm-kpi-picker" id="hm-kpi-picker">' +
|
||||||
|
kpiCatalog.map(function (k) {
|
||||||
|
var on = kpis.indexOf(k.id) >= 0;
|
||||||
|
return '<label class="hm-kpi-chip' + (on ? ' active' : '') + '" draggable="true" data-kpi-id="' + k.id + '">' +
|
||||||
|
'<input type="checkbox" data-kpi-toggle="' + k.id + '"' + (on ? ' checked' : '') + '> ' +
|
||||||
|
(k.icon || '') + ' ' + (k.label || k.id) + '</label>';
|
||||||
|
}).join('') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
var picker = container.querySelector('#hm-kpi-picker');
|
||||||
|
var dragKpi = null;
|
||||||
|
|
||||||
|
container.querySelectorAll('[data-widget-toggle]').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
widgets[cb.dataset.widgetToggle] = cb.checked;
|
||||||
|
if (onChange) onChange({ widgets: Object.assign({}, widgets) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncKpiOrder() {
|
||||||
|
var chips = Array.from(picker.querySelectorAll('.hm-kpi-chip'));
|
||||||
|
kpis = chips.filter(function (c) { return c.querySelector('input').checked; })
|
||||||
|
.map(function (c) { return c.dataset.kpiId; });
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll('[data-kpi-toggle]').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
var chip = cb.closest('.hm-kpi-chip');
|
||||||
|
chip.classList.toggle('active', cb.checked);
|
||||||
|
syncKpiOrder();
|
||||||
|
if (onChange) onChange({ kpis: kpis.slice() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
picker.querySelectorAll('.hm-kpi-chip').forEach(function (chip) {
|
||||||
|
chip.addEventListener('dragstart', function (ev) {
|
||||||
|
dragKpi = chip;
|
||||||
|
chip.classList.add('hm-kpi-dragging');
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
chip.addEventListener('dragend', function () {
|
||||||
|
chip.classList.remove('hm-kpi-dragging');
|
||||||
|
dragKpi = null;
|
||||||
|
syncKpiOrder();
|
||||||
|
if (onChange) onChange({ kpis: kpis.slice() });
|
||||||
|
});
|
||||||
|
chip.addEventListener('dragover', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!dragKpi || dragKpi === chip) return;
|
||||||
|
var rect = chip.getBoundingClientRect();
|
||||||
|
var after = ev.clientX > rect.left + rect.width / 2;
|
||||||
|
if (after) chip.after(dragKpi); else chip.before(dragKpi);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
global.DashboardLayout = {
|
global.DashboardLayout = {
|
||||||
WIDGET_META: WIDGET_META,
|
WIDGET_META: WIDGET_META,
|
||||||
|
KPI_META: KPI_META,
|
||||||
|
DEFAULT_KPIS: DEFAULT_KPIS,
|
||||||
loadPrefs: loadPrefs,
|
loadPrefs: loadPrefs,
|
||||||
saveLayout: saveLayout,
|
saveLayout: saveLayout,
|
||||||
saveViz: saveViz,
|
saveViz: saveViz,
|
||||||
|
savePrefs: savePrefs,
|
||||||
|
saveDashboardConfig: saveDashboardConfig,
|
||||||
|
visibleLayout: visibleLayout,
|
||||||
applyOrder: applyOrder,
|
applyOrder: applyOrder,
|
||||||
|
applyVisibility: applyVisibility,
|
||||||
initDrag: initDrag,
|
initDrag: initDrag,
|
||||||
buildVizSelector: buildVizSelector,
|
buildVizSelector: buildVizSelector,
|
||||||
|
buildCustomizePanel: buildCustomizePanel,
|
||||||
};
|
};
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<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/hermes.css" />
|
||||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=14" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="herman-shell hm-neo">
|
<div class="herman-shell hm-neo">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<div class="briefing-meta" id="briefing-meta"></div>
|
<div class="briefing-meta" id="briefing-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="herman-briefing-actions">
|
<div class="herman-briefing-actions">
|
||||||
<button type="button" class="btn btn-sm" @click="editLayout = !editLayout" x-text="editLayout ? 'Klaar ordenen' : '⋮⋮ Ordenen'"></button>
|
<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" @click="refreshLive()" :disabled="loading">↻ Live data</button>
|
||||||
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</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="/clients" class="btn btn-pulse-green">CRM Clients</a>
|
||||||
@@ -45,17 +45,18 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||||
|
<div id="dashboard-customize" class="hm-customize-wrap" x-show="editLayout" x-cloak></div>
|
||||||
|
|
||||||
<div id="briefing-dashboard" class="hm-dash-layout">
|
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''">
|
||||||
|
|
||||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="kpis">
|
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · CEO KPI's</div>
|
<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</h3>
|
<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>
|
||||||
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
|
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="executive">
|
<section class="hm-dash-section hm-dash-full" data-widget="executive">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Executive</div>
|
<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>
|
<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-panel">
|
||||||
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
|
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
|
||||||
@@ -64,8 +65,8 @@
|
|||||||
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
|
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="briefing">
|
<section class="hm-dash-section hm-dash-full" data-widget="briefing">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Briefing</div>
|
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
|
||||||
<h3 class="hm-section-title">Herman briefing</h3>
|
<h3 class="hm-section-title">Herman briefing</h3>
|
||||||
<div class="briefing-summary-grid">
|
<div class="briefing-summary-grid">
|
||||||
<div class="briefing-card summary">
|
<div class="briefing-card summary">
|
||||||
@@ -89,8 +90,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hm-dash-section hm-widget-draggable" data-widget="retail">
|
<section class="hm-dash-section hm-dash-full" data-widget="rss">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Retail</div>
|
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · RSS feed</div>
|
||||||
|
<h3 class="hm-section-title"><span class="hm-live-dot"></span> RSS live feed</h3>
|
||||||
|
<div class="hm-chart-panel">
|
||||||
|
<div id="briefing-rss-feed" class="hm-rss-panel"></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>
|
<h3 class="hm-section-title">Retail operatie</h3>
|
||||||
<div class="hm-dash-two-col">
|
<div class="hm-dash-two-col">
|
||||||
<div class="hm-chart-panel">
|
<div class="hm-chart-panel">
|
||||||
@@ -109,8 +118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hm-dash-section hm-dash-full hm-dash-analytics hm-widget-draggable" data-widget="analytics">
|
<section class="hm-dash-section hm-dash-full hm-dash-analytics" data-widget="analytics">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Analytics</div>
|
<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>
|
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
|
||||||
<div class="hm-neo-charts">
|
<div class="hm-neo-charts">
|
||||||
<div class="hm-chart-panel hm-analytics-panel">
|
<div class="hm-chart-panel hm-analytics-panel">
|
||||||
@@ -149,8 +158,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="hub-grid hm-dash-feed hm-widget-draggable" data-widget="feed">
|
<div class="hub-grid hm-dash-feed" data-widget="feed">
|
||||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Feed</div>
|
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Feed</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
|
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
|
||||||
<div id="agent-feed-live">
|
<div id="agent-feed-live">
|
||||||
@@ -175,8 +184,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||||
<script src="/static/js/dashboard-layout.js?v=1"></script>
|
<script src="/static/js/dashboard-layout.js?v=3"></script>
|
||||||
<script src="/static/js/briefing-charts.js?v=11"></script>
|
<script src="/static/js/briefing-charts.js?v=12"></script>
|
||||||
<script>
|
<script>
|
||||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||||
function dashboardBriefing() {
|
function dashboardBriefing() {
|
||||||
@@ -184,25 +193,74 @@ function dashboardBriefing() {
|
|||||||
loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
|
loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
|
||||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||||
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
||||||
|
_prefs: null, _setDragMode: null, _saveTimer: null,
|
||||||
|
applyDashboardPrefs(prefs) {
|
||||||
|
this._prefs = prefs;
|
||||||
|
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
|
||||||
|
const dash = document.getElementById('briefing-dashboard');
|
||||||
|
const order = DashboardLayout.visibleLayout(prefs);
|
||||||
|
DashboardLayout.applyOrder(dash, order);
|
||||||
|
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
|
||||||
|
if (this._setDragMode) this._setDragMode(this.editLayout);
|
||||||
|
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
|
||||||
|
},
|
||||||
|
scheduleSave(patch) {
|
||||||
|
if (!this._prefs) return;
|
||||||
|
if (patch.widgets) this._prefs.dashboard_widgets = patch.widgets;
|
||||||
|
if (patch.kpis) this._prefs.dashboard_kpis = patch.kpis;
|
||||||
|
if (patch.layout) this._prefs.dashboard_layout = patch.layout;
|
||||||
|
clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = setTimeout(async () => {
|
||||||
|
await DashboardLayout.saveDashboardConfig(
|
||||||
|
this._prefs.dashboard_layout,
|
||||||
|
this._prefs.dashboard_widgets,
|
||||||
|
this._prefs.dashboard_kpis
|
||||||
|
);
|
||||||
|
this.applyDashboardPrefs(this._prefs);
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
toggleEditLayout() {
|
||||||
|
this.editLayout = !this.editLayout;
|
||||||
|
if (this._setDragMode) this._setDragMode(this.editLayout);
|
||||||
|
},
|
||||||
async init() {
|
async init() {
|
||||||
window._dashboardVizMode = this._vizMode;
|
window._dashboardVizMode = this._vizMode;
|
||||||
const prefs = await DashboardLayout.loadPrefs();
|
const prefs = await DashboardLayout.loadPrefs();
|
||||||
|
this._prefs = prefs;
|
||||||
this._vizMode = prefs.global_viz_mode || 'neo-bars';
|
this._vizMode = prefs.global_viz_mode || 'neo-bars';
|
||||||
window._dashboardVizMode = this._vizMode;
|
window._dashboardVizMode = this._vizMode;
|
||||||
DashboardLayout.applyOrder(document.getElementById('briefing-dashboard'), prefs.dashboard_layout || []);
|
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
|
||||||
DashboardLayout.initDrag(document.getElementById('briefing-dashboard'), (order) => DashboardLayout.saveLayout(order));
|
const dash = document.getElementById('briefing-dashboard');
|
||||||
|
this.applyDashboardPrefs(prefs);
|
||||||
|
this._setDragMode = DashboardLayout.initDrag(dash, (order) => {
|
||||||
|
const widgets = this._prefs.dashboard_widgets || {};
|
||||||
|
const hidden = (this._prefs.dashboard_layout || []).filter(id => widgets[id] === false);
|
||||||
|
const full = order.concat(hidden.filter(id => order.indexOf(id) < 0));
|
||||||
|
this._prefs.dashboard_layout = full;
|
||||||
|
this.scheduleSave({ layout: full });
|
||||||
|
}, false);
|
||||||
|
DashboardLayout.buildCustomizePanel(document.getElementById('dashboard-customize'), prefs, (patch) => {
|
||||||
|
this.scheduleSave(patch);
|
||||||
|
if (patch.widgets) this.applyDashboardPrefs(this._prefs);
|
||||||
|
if (patch.kpis && this._stats) BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, patch.kpis);
|
||||||
|
});
|
||||||
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
|
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
|
||||||
this._vizMode = mode;
|
this._vizMode = mode;
|
||||||
window._dashboardVizMode = mode;
|
window._dashboardVizMode = mode;
|
||||||
await DashboardLayout.saveViz(mode);
|
await DashboardLayout.saveViz(mode);
|
||||||
document.getElementById('viz-label-sentiment').textContent = mode;
|
document.getElementById('viz-label-sentiment').textContent = mode;
|
||||||
document.getElementById('viz-label-agents').textContent = mode;
|
document.getElementById('viz-label-agents').textContent = mode;
|
||||||
if (this._stats) BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, mode);
|
if (this._stats) BriefingCharts.renderLive(dash, this._stats, mode, window._dashboardKpis);
|
||||||
});
|
});
|
||||||
|
window._refreshRssDash = () => this.refreshRss(true);
|
||||||
await this.refreshLive(false);
|
await this.refreshLive(false);
|
||||||
|
if (!sessionStorage.getItem('rss_auto_refreshed')) {
|
||||||
|
const cnt = (this._stats && (this._stats.rss_items || (this._stats.rss_live || []).length)) || 0;
|
||||||
|
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
|
||||||
|
}
|
||||||
await this.loadApprovals();
|
await this.loadApprovals();
|
||||||
await this.loadProjectsMini();
|
await this.loadProjectsMini();
|
||||||
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
|
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
|
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
|
||||||
this._wsStop = CockpitLive.connectFeed((data) => {
|
this._wsStop = CockpitLive.connectFeed((data) => {
|
||||||
@@ -210,6 +268,21 @@ function dashboardBriefing() {
|
|||||||
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async refreshRss(toast) {
|
||||||
|
if (toast) Cockpit.toast('RSS feeds ophalen…', 'info');
|
||||||
|
try {
|
||||||
|
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||||
|
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
|
||||||
|
if (this._stats && live.items) {
|
||||||
|
this._stats.rss_live = live.items;
|
||||||
|
this._stats.rss_items = live.count || live.items.length;
|
||||||
|
BriefingCharts.renderRssFeed(document.getElementById('briefing-rss-feed'), this._stats);
|
||||||
|
BriefingCharts.renderRetail(document.getElementById('briefing-retail'), this._stats);
|
||||||
|
BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, window._dashboardKpis);
|
||||||
|
}
|
||||||
|
if (toast) Cockpit.toast('RSS bijgewerkt (' + (live.count || 0) + ' items)', 'success');
|
||||||
|
} catch (e) { if (toast) Cockpit.toast(e.message || 'RSS fout', 'error'); }
|
||||||
|
},
|
||||||
async loadApprovals() {
|
async loadApprovals() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
|
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
|
||||||
@@ -271,9 +344,16 @@ function dashboardBriefing() {
|
|||||||
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
||||||
if (r.stats) {
|
if (r.stats) {
|
||||||
this._stats = r.stats;
|
this._stats = r.stats;
|
||||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats, this._vizMode);
|
try {
|
||||||
this.renderClientsMini(r.stats);
|
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
|
||||||
this.renderActivityLog(r.stats.activity_log || []);
|
if (live.items && live.items.length) {
|
||||||
|
this._stats.rss_live = live.items;
|
||||||
|
this._stats.rss_items = live.count || live.items.length;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
|
||||||
|
this.renderClientsMini(this._stats);
|
||||||
|
this.renderActivityLog(this._stats.activity_log || []);
|
||||||
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
||||||
}
|
}
|
||||||
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Dashboard widget visibility + KPI selection
|
||||||
|
ALTER TABLE user_ui_preferences ADD COLUMN IF NOT EXISTS dashboard_widgets JSONB DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE user_ui_preferences ADD COLUMN IF NOT EXISTS dashboard_kpis JSONB DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
-- Ensure RSS widget in default layout for existing users
|
||||||
|
UPDATE user_ui_preferences
|
||||||
|
SET dashboard_layout = (
|
||||||
|
SELECT jsonb_agg(elem)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT elem
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
COALESCE(dashboard_layout, '[]'::jsonb) || '["rss"]'::jsonb
|
||||||
|
) AS elem
|
||||||
|
) s
|
||||||
|
)
|
||||||
|
WHERE dashboard_layout IS NOT NULL
|
||||||
|
AND NOT dashboard_layout @> '["rss"]'::jsonb;
|
||||||
Reference in New Issue
Block a user