Compare commits

..

19 Commits

Author SHA1 Message Date
Aissa f925042a93 Stop War Room A2A loops and unlock full chat history with dates.
Detect repeating topics and pause A2A, load older messages beyond the old ~120 cap, and show clear date/time stamps on every bubble.
2026-08-16 23:23:48 +00:00
Aissa c5ea8cbb63 Ship War Room UX, CEO inbox, mobile chat, and Agents NAS workspace.
Make A2A togglable, keep reply context while reading, route agent data to //10.4.7.11/share/Agents, and polish themes plus export-intel sync cancel.
2026-08-16 22:40:32 +00:00
Aissa 910ba465d7 Add War Room voice meeting, fix chat overlay, and ship 5 cyan UI themes.
Voice Room lets humans talk with agents via Whisper/TTS; chat typing no longer blocked by a full-screen overlay, attachments/avatars are larger, and Settings exposes five vivid mid-dark cyan/blue themes for the whole cockpit.
2026-08-16 15:49:29 +00:00
sysops 1dd607b060 SysOps: approved_backup — 2026-08-16 11:01 UTC 2026-08-16 11:01:58 +00:00
Aissa 22a17ed048 Activate agent mesh with honest live status and working animations.
Souls now aggregate aliased events (e.g. halal_engine), KPIs reflect 6h activity, active agents animate, and morning scheduler/scripts target cockpit :8610.
2026-08-01 17:59:32 +00:00
Aissa 5e43b6b246 Ship Herman chat, Hermes KPI explorer, and intelligence analytics.
Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
2026-08-01 17:50:45 +00:00
Aissa 62239f0a4e Improve CEO cockpit clarity and Revenue visuals, with Hermes briefing.
Tighten overview KPIs/pipeline height, add selectable Revenue views and a resizable sidebar, and keep supporting Hermes/Wereldexport updates.
2026-07-30 11:54:54 +00:00
sysops 5dd69a1138 SysOps: manual — 2026-07-30 09:33 UTC 2026-07-30 09:33:03 +00:00
aissa f21d58483b refactor: cleaner briefing layout - business sections, no IT clutter 2026-07-30 09:28:03 +00:00
Aissa 0d74c146aa Enlarge Wereldexport world map to full usable size.
Stack map full-width with 62vh height; on wide screens use 65/35 split and taller map area.
2026-07-19 18:55:20 +00:00
Aissa ff22498dcd Improve cockpit typography for readability across all pages.
Raise base font to 16px, enlarge sidebar, buttons, tables and headings, add Inter font and global typography layer.
2026-07-19 18:49:52 +00:00
Aissa 8d2766efb6 Fix map layout jump, CRM/edit distributeurs, progress overlay.
Stable map grid, no fitBounds on world view, entity PATCH edit form, CRM buttons on distributors, and loading animation for sync/wait states.
2026-07-19 18:47:12 +00:00
Aissa 6856d09cd1 Speed up Wereldexport map loading dramatically.
Limit world overview payload, optimize SQL joins, cache bundle and tiles, reuse map instance, and show loading state.
2026-07-19 18:42:47 +00:00
Aissa 7289b770e5 Fix Wereldexport world map loading and tiles.
Self-host Leaflet locally, proxy map tiles through cockpit, set explicit map height, and invalidate size after init.
2026-07-19 18:36:02 +00:00
Aissa 8712f503b1 Fix sidebar scrollbar between nav and content.
Remove independent sidebar scroll so the page scrolls as one unit; add dark themed scrollbars and bump CSS cache versions.
2026-07-19 18:31:43 +00:00
Aissa 171878ee1d Polish CEO dashboard UI with fixed live dock and cleaner layout.
Remove page scroll hijacking during briefing generation, replace nested scroll panels with timeline and grid layouts, and refine the command bar and hero for a more professional feel.
2026-07-19 18:26:17 +00:00
Aissa 4dd1b8265f Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
2026-07-19 18:25:21 +00:00
sysops 544d9e611d SysOps: homarr-dashboard-update — 2026-06-23 10:14 UTC 2026-06-23 10:14:48 +00:00
sysops 26fe76afdd SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC 2026-06-23 10:04:23 +00:00
238 changed files with 68027 additions and 2599 deletions
+10
View File
@@ -21,3 +21,13 @@ PROXMOX_HOST=10.4.7.14
PROXMOX_USER=root@pam
PROXMOX_TOKEN_NAME=
PROXMOX_TOKEN_VALUE=
# OpenRouter (optioneel — ook via Instellingen → AI / LLM in cockpit)
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# orchestrator | router | auto (auto = orchestrator met fallback naar standaard LLM)
HERMAN_LLM_BACKEND=auto
# Google Places (Export Intel — telefoon/website verrijking)
GOOGLE_MAPS_API_KEY=
+9
View File
@@ -13,3 +13,12 @@ cockpit/static/uploads/
*.swp
.idea/
.vscode/
.cursor/
tmp/
cockpit/tmp-upload/
# Local TLS private keys (self-signed cockpit HTTPS)
cockpit/caddy/certs/*.key
cockpit/caddy/certs/key.pem
cockpit/caddy/certs/*.srl
cockpit/caddy/certs/*.csr
+21
View File
@@ -0,0 +1,21 @@
-- Docling conversion results for NAS documents
CREATE TABLE IF NOT EXISTS docling_results (
id SERIAL PRIMARY KEY,
storage_path TEXT NOT NULL,
file_sig VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'pending',
options JSONB NOT NULL DEFAULT '{}'::jsonb,
exports JSONB NOT NULL DEFAULT '{}'::jsonb,
tables_data JSONB NOT NULL DEFAULT '[]'::jsonb,
pictures JSONB NOT NULL DEFAULT '[]'::jsonb,
page_count INT DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (storage_path, file_sig)
);
CREATE INDEX IF NOT EXISTS idx_docling_results_path ON docling_results (storage_path);
CREATE INDEX IF NOT EXISTS idx_docling_results_status ON docling_results (status, created_at DESC);
+10
View File
@@ -0,0 +1,10 @@
-- Editable workspace drafts for Docling / document editor
CREATE TABLE IF NOT EXISTS document_workspace (
storage_path TEXT PRIMARY KEY,
source_format VARCHAR(32) DEFAULT 'markdown',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_document_workspace_updated ON document_workspace (updated_at DESC);
+30
View File
@@ -0,0 +1,30 @@
-- Document ↔ klant/project koppelingen + auto-ingest log
CREATE TABLE IF NOT EXISTS document_links (
id SERIAL PRIMARY KEY,
storage_path TEXT NOT NULL,
is_folder BOOLEAN NOT NULL DEFAULT FALSE,
client_id INT REFERENCES clients(id) ON DELETE CASCADE,
project_id INT REFERENCES cockpit_projects(id) ON DELETE SET NULL,
link_type VARCHAR(32) NOT NULL DEFAULT 'nas_share',
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(64) DEFAULT 'cockpit'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_links_unique
ON document_links (storage_path, COALESCE(client_id, 0), COALESCE(project_id, 0));
CREATE INDEX IF NOT EXISTS idx_document_links_client ON document_links (client_id);
CREATE INDEX IF NOT EXISTS idx_document_links_project ON document_links (project_id);
CREATE INDEX IF NOT EXISTS idx_document_links_path ON document_links (storage_path);
CREATE TABLE IF NOT EXISTS ingest_automation_log (
id SERIAL PRIMARY KEY,
source VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ok',
details JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ingest_automation_log_at ON ingest_automation_log (created_at DESC);
+2265
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
"""Automatische NAS + second-brain synchronisatie."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Any
import httpx
from app.db import execute, fetch_all, fetch_one
log = logging.getLogger("cockpit.auto_ingest")
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
try:
execute(
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
(source, status, json.dumps(details)),
)
except Exception as exc:
log.warning("ingest log failed: %s", exc)
def _brain_chat_id_for_client(client_id: int) -> int:
return -int(client_id)
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
rows = fetch_all(
"""
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
da.word_count, dl.client_id, c.name AS client_name
FROM document_analytics da
LEFT JOIN document_links dl ON (
da.storage_path = dl.storage_path
OR da.storage_path LIKE dl.storage_path || '/%'
) AND dl.is_folder = TRUE
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
WHERE da.storage_path IS NOT NULL
ORDER BY da.analyzed_at DESC NULLS LAST
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
synced = 0
errors = 0
async with httpx.AsyncClient(timeout=120.0) as client:
for row in rows:
path = row.get("storage_path") or ""
if not path:
continue
client_id = row.get("client_id")
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
text = (
f"NAS document: {row.get('filename') or path}\n"
f"Pad: {path}\nType: {row.get('doc_type')}\n"
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
)
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
try:
r = await client.post(
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
json={
"chat_id": chat_id,
"direction": "inbound",
"role": "system",
"content_type": "document",
"content_text": text[:4000],
"agent_name": "auto-ingest",
"embed": True,
"chat_type": "client_brain" if client_id else "nas_corpus",
"user_name": row.get("client_name") or "NAS",
"content_json": {"storage_path": path, "client_id": client_id},
"telegram_message_id": msg_hash,
},
)
if r.status_code < 400:
synced += 1
else:
errors += 1
except Exception as exc:
log.warning("brain sync %s: %s", path, exc)
errors += 1
return {"synced": synced, "errors": errors, "candidates": len(rows)}
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
out: dict[str, Any] = {"ok": True, "steps": {}}
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
params={"force": "true" if force_scan else "false"},
)
r.raise_for_status()
out["steps"]["nas_scan"] = r.json()
except Exception as exc:
out["steps"]["nas_scan"] = {"error": str(exc)}
brain = await sync_nas_to_brain()
out["steps"]["brain_sync"] = brain
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
_log_run("auto_sync", status, out)
out["last_logged"] = True
return out
def last_auto_sync() -> dict[str, Any] | None:
row = fetch_one(
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
)
if not row:
return None
d = dict(row)
if hasattr(d.get("created_at"), "isoformat"):
d["created_at"] = d["created_at"].isoformat()
if isinstance(d.get("details"), str):
try:
d["details"] = json.loads(d["details"])
except Exception:
pass
return d
+9 -1
View File
@@ -560,6 +560,7 @@ def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]:
SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview,
jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count,
jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at,
session_id, created_at
FROM photo_imports ORDER BY created_at DESC LIMIT %s
""",
@@ -589,7 +590,11 @@ def photos_image(photo_id: int) -> Response:
@app.get("/photos/{photo_id}/detections")
def photos_detections(photo_id: int) -> dict[str, Any]:
row = fetch_one(
"SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s",
"""
SELECT id, detections, extracted_items, ocr_text,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
FROM photo_imports WHERE id = %s
""",
(photo_id,),
)
if not row:
@@ -600,6 +605,9 @@ def photos_detections(photo_id: int) -> dict[str, Any]:
"detections": row.get("detections") or [],
"extracted_items": row.get("extracted_items") or [],
"ocr_text": row.get("ocr_text") or "",
"user_labels": row.get("user_labels") or [],
"label_notes": row.get("label_notes") or "",
"labeled_at": row.get("labeled_at"),
}
+147
View File
@@ -0,0 +1,147 @@
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
from __future__ import annotations
from typing import Any
from app.db import fetch_all, fetch_one
from app.services import projects as projects_svc
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _serialize_rows(rows: list) -> list[dict]:
return [_serialize_row(r) for r in rows if r]
def get_client_360(client_id: int) -> dict[str, Any]:
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
if not client:
return {"ok": False, "error": "Client not found"}
projs = projects_svc.list_projects(client_id=client_id, limit=50)
links = fetch_all(
"""
SELECT dl.*, p.name AS project_name
FROM document_links dl
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
WHERE dl.client_id = %s
ORDER BY dl.created_at DESC
""",
(client_id,),
)
paths = [l["storage_path"] for l in links if l.get("storage_path")]
docs: list[dict] = []
if paths:
placeholders = ",".join(["%s"] * len(paths))
docs = fetch_all(
f"""
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
sentiment_compound, analyzed_at, user_labels
FROM document_analytics
WHERE storage_path IN ({placeholders})
OR storage_path LIKE ANY (
SELECT dl.storage_path || '/%%' FROM document_links dl
WHERE dl.client_id = %s AND dl.is_folder = TRUE
)
ORDER BY analyzed_at DESC NULLS LAST
LIMIT 80
""",
tuple(paths + [client_id]),
)
sentiment_rows = fetch_all(
"""
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
FROM document_analytics da
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY sentiment_label
""",
(client_id,),
)
top_words = fetch_all(
"""
SELECT dwc.lemma, SUM(dwc.count) AS total
FROM document_word_counts dwc
JOIN document_analytics da ON da.id = dwc.document_id
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
AND NOT dwc.is_stopword
GROUP BY dwc.lemma
ORDER BY total DESC
LIMIT 25
""",
(client_id,),
)
deals = fetch_all(
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
(client_id,),
)
stores = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
FROM client_supermarket_links l
JOIN supermarkets s ON s.id = l.supermarket_id
WHERE l.client_id = %s
LIMIT 30
""",
(client_id,),
)
trends = fetch_all(
"""
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
COUNT(*) AS docs,
AVG(da.sentiment_compound) AS avg_sentiment,
SUM(da.word_count) AS words
FROM document_analytics da
WHERE da.analyzed_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12
""",
(client_id,),
)
for t in trends:
if hasattr(t.get("month"), "isoformat"):
t["month"] = t["month"].isoformat()
return {
"ok": True,
"client": _serialize_row(client),
"projects": projs,
"links": _serialize_rows(links),
"documents": _serialize_rows(docs),
"sentiment_breakdown": _serialize_rows(sentiment_rows),
"top_words": _serialize_rows(top_words),
"deals": _serialize_rows(deals),
"stores": _serialize_rows(stores),
"monthly_trends": trends,
"stats": {
"linked_paths": len(links),
"documents": len(docs),
"projects": len(projs),
"stores": len(stores),
"deals": len(deals),
},
}
+161
View File
@@ -0,0 +1,161 @@
import json
from datetime import date, datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import (
build_live_digest,
collect_briefing_data,
generate_daily_briefing,
serialize_stats,
stream_daily_briefing,
)
router = APIRouter(prefix="/api", tags=["api"])
def _stats_payload() -> dict[str, Any]:
stats: dict[str, Any] = {
"deals": 0,
"clients": 0,
"pending_approvals": 0,
"pipeline_value": 0,
}
try:
row = fetch_one("SELECT COUNT(*) AS c FROM deals")
stats["deals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM clients")
stats["clients"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
stats["pending_approvals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one(
"SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
)
stats["pipeline_value"] = float(row["total"]) if row else 0
except Exception:
pass
return stats
@router.get("/server-time")
async def server_time():
now = datetime.utcnow()
return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"}
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
bookmarks = []
bookmark_map = {}
try:
bookmarks = fetch_all(
"""SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at
FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30"""
)
for b in bookmarks:
if b.get("created_at") and hasattr(b["created_at"], "isoformat"):
b["created_at"] = b["created_at"].isoformat()
bookmark_map[b["rss_item_id"]] = True
except Exception:
bookmarks = []
stats["rss_bookmarks"] = bookmarks
stats["rss_bookmark_ids"] = list(bookmark_map.keys())
if not stats.get("rss_live") and not stats.get("trending_food"):
try:
stats["rss_live"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
)
except Exception:
stats["rss_live"] = []
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "at": datetime.utcnow().isoformat()}
@router.post("/herman/briefing")
async def herman_briefing():
try:
content, stats = await generate_daily_briefing()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
@router.post("/herman/briefing/stream")
async def herman_briefing_stream():
"""SSE stream — live stappen tijdens dagrapport generatie."""
async def event_gen():
try:
async for event in stream_daily_briefing():
yield f"data: {json.dumps(event, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/herman/briefing/latest")
async def herman_briefing_latest():
try:
row = fetch_one(
"SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
live_stats = serialize_stats(collect_briefing_data())
if not row:
return {"ok": True, "content": None, "stats": live_stats}
if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
row["created_at"] = row["created_at"].isoformat()
return {"ok": True, **row, "stats": live_stats}
@router.get("/live/platform")
async def live_platform(limit: int = 100, agent: Optional[str] = None):
from app.services.platform_live import fetch_platform_events, platform_stats
events = fetch_platform_events(limit=min(limit, 200), agent=agent)
return {"ok": True, "stats": platform_stats(), "events": events}
@router.get("/events")
async def list_events(limit: int = 50):
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
for row in rows:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
return {"events": rows}
+292
View File
@@ -0,0 +1,292 @@
"""Cockpit user accounts: password hashing, CRUD, permissions."""
from __future__ import annotations
import hashlib
import json
import secrets
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
# Nav / feature keys shown in Settings → Gebruikers
PAGE_CATALOG: list[dict[str, str]] = [
{"key": "dashboard", "label": "Overzicht (CEO)"},
{"key": "revenue", "label": "Revenue Cockpit"},
{"key": "retail", "label": "Retail NL"},
{"key": "marketing", "label": "Marketing"},
{"key": "packaging", "label": "Packaging"},
{"key": "beurs", "label": "Beurs Live"},
{"key": "documents", "label": "Documenten"},
{"key": "projects", "label": "Projecten"},
{"key": "hermes", "label": "Telegram / Hermes"},
{"key": "analytics", "label": "Analytics"},
{"key": "export", "label": "Wereldexport"},
{"key": "clients", "label": "Klanten"},
{"key": "deals", "label": "Deals"},
{"key": "products", "label": "Producten"},
{"key": "suppliers", "label": "Leveranciers"},
{"key": "reports", "label": "Rapporten"},
{"key": "ops", "label": "IT Ops"},
{"key": "agents", "label": "Agents"},
{"key": "browser", "label": "Browser"},
{"key": "studio", "label": "Studio"},
{"key": "voice", "label": "Voice"},
{"key": "settings", "label": "Instellingen"},
]
# path prefix → permission key (order: longest prefix first)
PATH_RULES: list[tuple[str, str]] = [
("/revenue-cockpit", "revenue"),
("/export-intel", "export"),
("/packaging", "packaging"),
("/marketing", "marketing"),
("/documents", "documents"),
("/analytics", "analytics"),
("/suppliers", "suppliers"),
("/products", "products"),
("/projects", "projects"),
("/settings", "settings"),
("/clients", "clients"),
("/browser", "browser"),
("/monitor", "browser"),
("/hermes", "hermes"),
("/retail", "retail"),
("/reports", "reports"),
("/studio", "studio"),
("/agents", "agents"),
("/beurs", "beurs"),
("/deals", "deals"),
("/voice", "voice"),
("/ops", "ops"),
("/", "dashboard"),
]
ADMIN_SEED_USERNAME = "aissa"
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
rounds = 200_000
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algo, rounds_s, salt, digest = password_hash.split("$", 3)
if algo != "pbkdf2_sha256":
return False
rounds = int(rounds_s)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return secrets.compare_digest(dk.hex(), digest)
except Exception:
return False
def _normalize_perms(raw: Any) -> dict[str, Any]:
if raw is None:
return {"pages": [], "manage_users": False}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return {"pages": [], "manage_users": False}
if not isinstance(raw, dict):
return {"pages": [], "manage_users": False}
pages = raw.get("pages") or []
if not isinstance(pages, list):
pages = []
return {
"pages": [str(p) for p in pages],
"manage_users": bool(raw.get("manage_users")),
}
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
perms = _normalize_perms(row.get("permissions"))
role = row.get("role") or "user"
if role == "admin":
perms = {
"pages": [p["key"] for p in PAGE_CATALOG],
"manage_users": True,
}
return {
"id": row["id"],
"username": row["username"],
"display_name": row.get("display_name") or row["username"],
"role": role,
"is_active": bool(row.get("is_active", True)),
"avatar_url": row.get("avatar_url") or None,
"permissions": perms,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
}
def ensure_admin_seed() -> None:
"""Create default admin aissa if table empty / user missing."""
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
if row:
return
execute(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, 'admin', %s::jsonb)
ON CONFLICT (username) DO NOTHING
""",
(
ADMIN_SEED_USERNAME,
hash_password(ADMIN_SEED_PASSWORD),
"Aissa",
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
),
)
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
(username.strip(),),
)
if not row or not row.get("is_active"):
return None
if not verify_password(password, row["password_hash"]):
return None
execute(
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(row["id"],),
)
return public_user(row)
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
return public_user(row)
def list_users() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
return [public_user(r) for r in rows if r]
def create_user(
username: str,
password: str,
display_name: str | None,
role: str,
permissions: dict[str, Any],
) -> dict[str, Any]:
username = username.strip()
if not username or not password:
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
if role not in ("admin", "user"):
role = "user"
perms = _normalize_perms(permissions)
if role == "admin":
perms["manage_users"] = True
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
row = fetch_one(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, %s, %s::jsonb)
RETURNING *
""",
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
)
return public_user(row) # type: ignore
def update_user(
user_id: int,
*,
display_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
permissions: dict[str, Any] | None = None,
password: str | None = None,
) -> dict[str, Any]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
new_name = display_name if display_name is not None else row.get("display_name")
new_role = role if role in ("admin", "user") else row["role"]
new_active = row["is_active"] if is_active is None else bool(is_active)
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
if new_role == "admin":
new_perms["manage_users"] = True
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
pw_hash = row["password_hash"]
if password:
pw_hash = hash_password(password)
updated = fetch_one(
"""
UPDATE cockpit_users
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
password_hash = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
)
return public_user(updated) # type: ignore
def delete_user(user_id: int, *, actor_id: int) -> None:
if user_id == actor_id:
raise ValueError("Je kunt jezelf niet verwijderen")
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
if row["role"] == "admin":
admins = fetch_one(
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
)
if admins and admins["n"] <= 1:
raise ValueError("Laatste admin kan niet verwijderd worden")
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
pages = set((user.get("permissions") or {}).get("pages") or [])
# normalize
p = path.split("?")[0] or "/"
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
for prefix, key in PATH_RULES:
if prefix == "/":
if p == "/" or p == "":
return "dashboard" in pages
continue
if p == prefix or p.startswith(prefix + "/"):
return key in pages
return "dashboard" in pages
def can_manage_users(user: dict[str, Any] | None) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
return bool((user.get("permissions") or {}).get("manage_users"))
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
row = fetch_one(
"""
UPDATE cockpit_users
SET avatar_url = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(avatar_url, user_id),
)
if not row:
raise ValueError("Gebruiker niet gevonden")
return public_user(row) # type: ignore
+22
View File
@@ -10,9 +10,31 @@ class Settings:
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
# OpenRouter (optioneel — key ook via Instellingen → AI / LLM)
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-v4-flash")
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")
WARROOM_MEET_BASE: str = os.getenv("WARROOM_MEET_BASE", "https://meet.jit.si")
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")
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
# Bind cockpit chat aan Hermes/Telegram memory (chat_id als OpenAI "user")
HERMES_CEO_CHAT_ID: str = os.getenv("HERMES_CEO_CHAT_ID", "8859782446")
HERMES_CTO_CHAT_ID: str = os.getenv("HERMES_CTO_CHAT_ID", "789036463")
HERMES_MEMORY_LIMIT: int = int(os.getenv("HERMES_MEMORY_LIMIT", "24"))
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
SESSION_SECRET: str = os.getenv("SESSION_SECRET", "foodlinkk-cockpit-session-change-me-2026")
SESSION_MAX_AGE: int = int(os.getenv("SESSION_MAX_AGE", str(60 * 60 * 24 * 14))) # 14 days
@property
def database_dsn(self) -> str:
+158 -1
View File
@@ -3,8 +3,11 @@ import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import Response
from app.db import close_pool, fetch_all, init_pool
from app.routes import (
@@ -31,9 +34,15 @@ from app.routes import (
packaging,
ops,
ops_api,
revenue_cockpit,
export_intel,
)
from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes import auth_routes
from app import auth_users
from app.config import settings as app_settings
from app.routes.agents_api import router as agents_api_router
from app.routes.marketing_api import router as marketing_api_router
from app.routes.projects_api import router as projects_api_router
@@ -41,8 +50,146 @@ from app.routes.projects_api import router as projects_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class NoCacheStaticFiles(StaticFiles):
"""Serve static assets without browser/SW long-lived caching."""
async def get_response(self, path: str, scope) -> Response:
response = await super().get_response(path, scope)
if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
PUBLIC_PREFIXES = (
"/login",
"/logout",
"/static/",
"/sw.js",
"/manifest.webmanifest",
"/static/manifest.json",
"/api/auth/login",
)
PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"}
def _is_public(path: str) -> bool:
if path in PUBLIC_EXACT:
return True
for pref in PUBLIC_PREFIXES:
if path == pref or path.startswith(pref):
return True
return False
class AuthGateMiddleware:
"""Require login; must run *inside* SessionMiddleware."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
from starlette.requests import Request
request = Request(scope, receive=receive)
path = request.url.path
user = None
try:
uid = request.session.get("user_id")
except Exception:
uid = None
if uid:
try:
user = auth_users.get_user_by_id(int(uid))
except Exception:
user = None
if not user or not user.get("is_active"):
try:
request.session.clear()
except Exception:
pass
user = None
request.state.user = user
async def call_next():
await self.app(scope, receive, send)
if _is_public(path):
await self.app(scope, receive, send)
return
if not user:
if path.startswith("/api/") or path.startswith("/ws"):
response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401)
else:
nxt = path
if request.url.query:
nxt = f"{path}?{request.url.query}"
from urllib.parse import quote
response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303)
await response(scope, receive, send)
return
if (
not path.startswith("/api/")
and not path.startswith("/ws")
and not path.startswith("/static")
and not auth_users.path_allowed(user, path)
):
response = RedirectResponse("/", status_code=303)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
# Order: first added = innermost. Session must be outermost so request.session works.
app.add_middleware(AuthGateMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=app_settings.SESSION_SECRET,
session_cookie="flk_session",
same_site="lax",
https_only=False,
max_age=app_settings.SESSION_MAX_AGE,
)
@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False)
async def service_worker():
"""Root-scoped service worker for installable PWA (Android + iOS Safari)."""
sw_path = BASE_DIR / "static" / "sw.js"
return FileResponse(
sw_path,
media_type="application/javascript; charset=utf-8",
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False)
async def web_manifest():
man_path = BASE_DIR / "static" / "manifest.json"
return FileResponse(
man_path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
@@ -60,12 +207,16 @@ for r in (
reports.router,
voice.router,
settings.router,
auth_routes.router,
browser.router,
herman.router,
studio.router,
hermes.router,
packaging.router,
ops.router,
revenue_cockpit.router,
export_intel.router,
revenue_cockpit_api,
api.router,
ops_api.router,
admin_router,
@@ -76,6 +227,8 @@ for r in (
agents_api_router,
marketing_api_router,
projects_api_router,
auth_routes.api_router,
auth_routes.users_api,
):
app.include_router(r)
@@ -83,6 +236,10 @@ for r in (
@app.on_event("startup")
def on_startup() -> None:
init_pool()
try:
auth_users.ensure_admin_seed()
except Exception as e:
print(f"auth seed warning: {e}")
@app.on_event("shutdown")
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
@@ -21,9 +21,9 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh", "approvals"}:
active_tab = "souls"
active_tab = request.query_params.get("tab", "warroom")
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "warroom"
events: list = []
try:
events = fetch_all(
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
)
@router.get("/warroom/popup", response_class=HTMLResponse)
async def agents_warroom_popup(request: Request):
return templates.TemplateResponse(
"agents_warroom_popup.html",
{"request": request, "page_title": "War Room"},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
+714 -35
View File
@@ -1,13 +1,18 @@
"""Agents API — souls & activity."""
from __future__ import annotations
import asyncio
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.db import fetch_all
from app.services import agent_approvals, agent_souls
from app.db import fetch_all, fetch_one
from app.services import agent_approvals, agent_integration, agent_souls
from app.services.agent_names import normalize_agent_key
from app.services.agent_terminal import execute_terminal_command
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
class HandoffBody(BaseModel):
from_agent: str
to_agent: str
handoff_type: str = "partner"
payload: dict[str, Any] = Field(default_factory=dict)
correlation_id: Optional[str] = None
class TerminalCommandBody(BaseModel):
command: str = Field(..., min_length=1, max_length=2000)
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
from datetime import datetime, timedelta, timezone
rows = fetch_all(
"""
SELECT agent_name, title, status, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
"""
)
now = datetime.now(timezone.utc)
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
key = normalize_agent_key(str(r.get("agent_name") or ""))
if not key:
continue
bucket = by_key.setdefault(
key,
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
)
created = r.get("created_at")
if bucket["last_event_at"] is None and created is not None:
bucket["last_event_at"] = created
bucket["last_event_title"] = r.get("title")
if created is not None:
if hasattr(created, "tzinfo") and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created >= now - timedelta(hours=6):
bucket["events_6h"] += 1
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
bucket["errors_24h"] += 1
return by_key
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
if events_6h > 0 and errors_24h == 0:
return "healthy"
if events_6h > 0:
return "warn"
if event_count > 0:
return "idle"
return "offline"
@router.get("/collaboration")
def api_collaboration() -> dict[str, Any]:
items = agent_integration.list_collaboration()
return {"items": items, "count": len(items)}
@router.post("/handoff")
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
try:
handoff = agent_integration.create_handoff(
body.from_agent,
body.to_agent,
handoff_type=body.handoff_type,
payload=body.payload,
correlation_id=body.correlation_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "handoff": handoff}
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
ev["created_at"] = ev["created_at"].isoformat()
meta = ev.get("metadata")
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
ev["metadata"] = meta or {}
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
if str(ev.get("event_type") or "").startswith("terminal_"):
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
if ev["metadata"].get("target_agent"):
line_type = "handoff_out"
if ev["metadata"].get("source_agent"):
line_type = "handoff_in"
if str(ev.get("status") or "") in ("error", "rejected"):
line_type = "error"
return {
"type": line_type,
"id": ev.get("id"),
"agent": normalize_agent_key(ev.get("agent_name")),
"message": ev.get("title") or ev.get("event_type"),
"detail": (ev.get("body") or "")[:500],
"status": ev.get("status"),
"correlation_id": ev["metadata"].get("correlation_id"),
"at": ev.get("created_at"),
}
@router.get("/souls/{agent_key}/terminal/stream")
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
key = normalize_agent_key(agent_key)
soul = agent_souls.get_soul(key)
if not soul:
raise HTTPException(404, "Agent not found")
async def event_gen():
last_id = 0
try:
hist = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s
ORDER BY id DESC
LIMIT 30
""",
(key,),
)
for row in reversed(hist):
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
while True:
try:
if correlation_id:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE metadata->>'correlation_id' = %s
AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(correlation_id, last_id),
)
else:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(key, last_id),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.4)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/live/stream")
async def api_agents_live_stream():
"""Single realtime stream for all agent terminals."""
async def event_gen():
last_id = 0
try:
recent = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '30 minutes'
ORDER BY id ASC
LIMIT 80
"""
)
for row in recent:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
payload["replay"] = True
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
while True:
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE id > %s
ORDER BY id ASC
LIMIT 100
""",
(last_id,),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.25)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
@router.post("/souls/{agent_key}/terminal/command")
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
try:
return await execute_terminal_command(agent_key, body.command.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
stats_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS agent_key,
MAX(created_at) AS last_event_at,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
COUNT(*) FILTER (
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND status IN ('error', 'rejected')
) AS errors_24h
FROM agent_events
GROUP BY LOWER(agent_name)
"""
)
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
by_key = _aggregate_agent_stats()
nodes: list[dict[str, Any]] = []
for soul in souls:
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
health = "offline"
if events_6h > 0 and errors_24h == 0:
health = "healthy"
elif events_6h > 0:
health = "warn"
elif int(soul.get("event_count") or 0) > 0:
health = "idle"
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
is_active = health == "healthy" and events_6h > 0
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
node["last_event_at"] = row["last_event_at"].isoformat()
node["is_active"] = is_active
node["last_event_title"] = row.get("last_event_title")
lat = row.get("last_event_at")
if lat is not None and hasattr(lat, "isoformat"):
node["last_event_at"] = lat.isoformat()
nodes.append(node)
edge_rows = fetch_all(
report_edges: list[dict[str, Any]] = []
for node in nodes:
key = str(node.get("agent_key") or "").lower()
if key == "herman" or not node.get("is_active"):
continue
report_edges.append(
{
"source": key,
"target": "herman",
"type": "report",
"active": True,
"weight": int(node.get("events_6h") or 1),
}
)
delegate_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
SELECT metadata, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND LOWER(agent_name) <> 'herman'
GROUP BY LOWER(agent_name)
ORDER BY weight DESC
WHERE agent_name = 'herman'
AND created_at >= NOW() - INTERVAL '6 hours'
AND (
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
OR metadata ? 'delegated'
)
ORDER BY created_at DESC
LIMIT 100
"""
)
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
delegate_edges: list[dict[str, Any]] = []
seen_delegate: set[tuple[str, str]] = set()
for r in delegate_rows:
meta = r.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
if isinstance(delegated, str):
delegated = [delegated]
channel = meta.get("channel") or "herman"
for agent in delegated:
tgt = normalize_agent_key(str(agent))
if not tgt or tgt == "herman":
continue
pair = ("herman", tgt)
if pair in seen_delegate:
continue
seen_delegate.add(pair)
delegate_edges.append(
{
"source": "herman",
"target": tgt,
"type": "delegate",
"active": True,
"channel": channel,
}
)
peer_static = [
{
"source": str(r["from_agent"]),
"target": str(r["to_agent"]),
"type": "static",
"handoff_type": r.get("handoff_type"),
}
for r in agent_integration.list_collaboration()
]
peer_live = agent_integration.peer_edges_live(hours=6)
peer_edges = peer_static + peer_live
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
executive_edges = []
if herman_active:
executive_edges = [
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
{"source": "herman", "target": "cto", "type": "executive", "active": True},
]
return {
"nodes": nodes,
"report_edges": report_edges,
"delegate_edges": delegate_edges,
"peer_edges": peer_edges,
"executive_edges": executive_edges,
"edges": report_edges,
"executives": [
{"id": "ceo", "label": "CEO", "role": "Aissa"},
{"id": "cto", "label": "CTO", "role": "Platform"},
],
}
@router.get("/approvals")
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
auto = None
rid = (req or {}).get("id")
if rid:
try:
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
except Exception:
auto = None
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
if auto:
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
@router.post("/approvals/{request_id}/approve")
@@ -224,3 +545,361 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req}
# --- War Room chat ---
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
reply_to_id: Optional[int] = None
@router.get("/warroom/rooms")
async def warroom_list_rooms() -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
return {"items": agent_chat.list_rooms()}
@router.post("/warroom/rooms/dm/{agent_key}")
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
from app.services import agent_chat
try:
room = agent_chat.get_or_create_dm(agent_key)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"room": room}
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(
room_id: int,
limit: int = 500,
after_id: int | None = None,
before_id: int | None = None,
) -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
lim = min(max(1, limit), 5000)
items = agent_chat.list_messages(
room_id, limit=lim, after_id=after_id, before_id=before_id
)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
total = agent_chat.count_messages(room_id)
oldest = agent_chat.oldest_message_id(room_id)
first_id = int(items[0]["id"]) if items else None
has_more = bool(oldest is not None and first_id is not None and first_id > oldest)
return {
"ok": True,
"room": room,
"items": items,
"deleted_ids": deleted_ids,
"total": total,
"has_more": has_more,
"oldest_id": oldest,
}
@router.post("/warroom/rooms/{room_id}/messages")
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
try:
result = await agent_chat.post_user_message(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.post("/warroom/rooms/{room_id}/messages/stream")
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
"""SSE stream: user → typing tokens → agent message(s) → done."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.post_user_message_stream(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/warroom/upload")
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
"""Upload a file to share in War Room (images/docs)."""
from pathlib import Path as P
import uuid
import re
raw_name = (file.filename or "upload.bin").strip()
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
ext = P(safe).suffix.lower()
allowed = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
}
if ext not in allowed:
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
data = await file.read()
if len(data) > 12 * 1024 * 1024:
raise HTTPException(400, "Max 12MB per bestand")
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
dest_dir.mkdir(parents=True, exist_ok=True)
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
(dest_dir / fname).write_bytes(data)
url = f"/static/uploads/warroom/{fname}"
return {
"ok": True,
"url": url,
"name": raw_name[:180],
"mime": file.content_type or "application/octet-stream",
"size": len(data),
}
@router.post("/warroom/digest")
async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]:
"""Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram."""
from app.services import warroom_digest
try:
return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
@router.post("/warroom/rooms/{room_id}/continue")
async def warroom_continue(request: Request, room_id: int, turns: int = 2):
"""Autonomous A2A continuation — agents keep debating with cockpit data."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.continue_warroom_stream(
room_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
max_turns=max(1, min(int(turns or 2), 4)),
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/warroom/ceo-inbox")
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
"""Direct messages from agents to CEO (@aissa)."""
from app.services import agent_chat
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
return {
"ok": True,
"items": items,
"unread": agent_chat.ceo_inbox_unread_count(),
}
class CeoInboxReadBody(BaseModel):
ids: Optional[list[int]] = None
all: bool = False
@router.post("/warroom/ceo-inbox/read")
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
from app.services import agent_chat
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
@router.delete("/warroom/rooms/{room_id}/messages")
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
"""Soft-delete all messages in a War Room / DM."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
n = agent_chat.clear_messages(room_id)
return {"ok": True, "cleared": n}
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
"""Soft-delete a War Room message."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
row = agent_chat.delete_message(room_id, message_id)
if not row:
raise HTTPException(404, "Bericht niet gevonden")
return {"ok": True, "id": message_id}
@router.post("/warroom/rooms/{room_id}/voice-room")
async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]:
"""Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS)."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
body = {}
try:
body = await request.json()
except Exception:
body = {}
action = str(body.get("action") or "join").lower()
agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"]
if not isinstance(agents, list):
agents = ["herman"]
agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12]
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
if action == "leave":
agent_chat.post_system_line(
f"🎙️ {who} verliet de Voice Room",
agent_key="herman",
metadata={"kind": "voice_room", "action": "leave", "opened_by": who},
)
return {"ok": True, "action": "leave"}
agent_chat.post_system_line(
f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents),
agent_key="herman",
metadata={
"kind": "voice_room",
"action": "join",
"opened_by": who,
"agents": agents,
},
)
return {"ok": True, "action": "join", "agents": agents, "host": who}
@router.post("/warroom/rooms/{room_id}/call")
async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]:
"""Open a simple no-account video room in an external HTTPS window.
Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab.
Default provider is Freifunk meet (no 8x8 login / no moderator gate).
"""
import os
from urllib.parse import quote
from app.db import execute, fetch_one
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/")
slug = f"FoodlinkkWR{room_id}"
if room.get("kind") == "dm" and room.get("agent_key"):
slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}"
url = f"{base}/{quote(slug)}"
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
recent = fetch_one(
"""SELECT id, metadata FROM warroom_messages
WHERE room_id = %s AND deleted_at IS NULL
AND metadata->>'kind' = 'call'
AND created_at > NOW() - INTERVAL '20 minutes'
ORDER BY id DESC LIMIT 1""",
(room_id,),
)
reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url)
if not reused:
execute(
"""UPDATE warroom_messages
SET deleted_at = NOW(), content = '',
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
AND (
metadata->>'kind' = 'call'
OR content ILIKE %s
OR content ILIKE %s
)""",
(room_id, "%War Room-call%", "%Call open%"),
)
agent_chat.post_system_line(
f"📹 Call open — geen account nodig: {url}",
agent_key="herman",
metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who},
)
return {
"ok": True,
"url": url,
"slug": slug,
"provider": "ffmuc",
"embed": False,
"mode": "external",
"reused": reused,
"hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.",
}
+34 -4
View File
@@ -1,17 +1,28 @@
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services.analytics_data import collect_analytics
from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings
router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class FindingIn(BaseModel):
kind: str = Field(default="insight", description="win|opportunity|attention|insight")
title: str
body: str = ""
score: float | None = None
source: str = "hermes"
href: str | None = None
meta: dict[str, Any] | None = None
@router.get("")
async def analytics_page(request: Request):
initial = collect_analytics({})
@@ -30,5 +41,24 @@ async def analytics_api(
days: int = Query(90, ge=7, le=365),
):
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
return JSONResponse(collect_analytics(filters))
@router.get("/api/findings")
async def analytics_findings(limit: int = Query(20, ge=1, le=100)):
return {"items": list_hermes_findings(limit)}
@router.post("/api/findings")
async def analytics_findings_create(payload: FindingIn = Body(...)):
"""Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard."""
row = add_finding(
kind=payload.kind,
title=payload.title,
body=payload.body,
score=payload.score,
source=payload.source,
href=payload.href,
meta=payload.meta,
)
return {"ok": True, "finding": row}
+19 -4
View File
@@ -9,7 +9,9 @@ from fastapi.responses import StreamingResponse
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import (
build_live_digest,
collect_briefing_data,
format_live_digest_text,
generate_daily_briefing,
serialize_stats,
stream_daily_briefing,
@@ -57,9 +59,9 @@ async def server_time():
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
async def herman_briefing_stats(light: bool = False):
"""Live stats from DB — light=1 skips halal engine for snelle poll-refresh."""
stats = serialize_stats(collect_briefing_data(skip_halal=light))
bookmarks = []
bookmark_map = {}
try:
@@ -86,7 +88,20 @@ async def herman_briefing_stats():
)
except Exception:
stats["rss_live"] = []
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "at": datetime.utcnow().isoformat()}
@router.get("/herman/briefing/live-text")
async def herman_briefing_live_text():
"""Compacte live samenvatting voor Telegram / bots."""
stats = serialize_stats(collect_briefing_data())
digest = build_live_digest(stats)
return {
"ok": True,
"text": format_live_digest_text(digest),
"live_digest": digest,
"at": datetime.utcnow().isoformat(),
}
@router.post("/herman/briefing")
+231
View File
@@ -0,0 +1,231 @@
"""Login / logout / me + user admin API."""
from __future__ import annotations
from pathlib import Path
import uuid
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app import auth_users
router = APIRouter(tags=["auth"])
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
raw_name = (file.filename or "avatar.jpg").lower()
ext = Path(raw_name).suffix
if ext not in ALLOWED_AVATAR:
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
data = await file.read()
if len(data) > 3 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Max 3 MB")
# strip old files for this user
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
path = AVATAR_DIR / fname
path.write_bytes(data)
return f"/static/uploads/avatars/{fname}"
class LoginBody(BaseModel):
username: str
password: str
class UserCreateBody(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
permissions: dict[str, Any] = Field(default_factory=dict)
class UserUpdateBody(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
permissions: Optional[dict[str, Any]] = None
def _session_user(request: Request) -> dict[str, Any] | None:
return getattr(request.state, "user", None)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
if _session_user(request):
return RedirectResponse(next or "/", status_code=303)
return templates.TemplateResponse(
"login.html",
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
)
@router.post("/login")
async def login_submit(request: Request):
form = await request.form()
username = str(form.get("username") or "").strip()
password = str(form.get("password") or "")
next_url = str(form.get("next") or "/")
if not next_url.startswith("/"):
next_url = "/"
user = auth_users.authenticate(username, password)
if not user:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"page_title": "Inloggen",
"next": next_url,
"error": "Ongeldige gebruikersnaam of wachtwoord",
"username": username,
},
status_code=401,
)
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return RedirectResponse(next_url, status_code=303)
@router.post("/logout")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@api_router.post("/login")
async def api_login(request: Request, body: LoginBody):
user = auth_users.authenticate(body.username, body.password)
if not user:
raise HTTPException(status_code=401, detail="Ongeldige login")
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return {"ok": True, "user": user}
@api_router.post("/logout")
async def api_logout(request: Request):
request.session.clear()
return {"ok": True}
@api_router.get("/me")
async def api_me(request: Request):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
return {
"user": user,
"pages": auth_users.PAGE_CATALOG,
"can_manage_users": auth_users.can_manage_users(user),
}
def _require_user_admin(request: Request) -> dict[str, Any]:
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
if not auth_users.can_manage_users(user):
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
return user
@users_api.get("")
async def users_list(request: Request):
_require_user_admin(request)
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
@users_api.post("")
async def users_create(request: Request, body: UserCreateBody):
_require_user_admin(request)
try:
user = auth_users.create_user(
body.username, body.password, body.display_name, body.role, body.permissions or {}
)
except Exception as e:
msg = str(e)
if "unique" in msg.lower() or "duplicate" in msg.lower():
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
raise HTTPException(status_code=400, detail=msg) from e
return {"user": user}
@users_api.put("/{user_id}")
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
_require_user_admin(request)
try:
user = auth_users.update_user(
user_id,
display_name=body.display_name,
role=body.role,
is_active=body.is_active,
permissions=body.permissions,
password=body.password or None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"user": user}
@users_api.delete("/{user_id}")
async def users_delete(request: Request, user_id: int):
actor = _require_user_admin(request)
try:
auth_users.delete_user(user_id, actor_id=actor["id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True}
@api_router.post("/me/avatar")
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
url = await _save_avatar_file(user["id"], file)
updated = auth_users.set_avatar(user["id"], url)
request.session["username"] = updated["username"]
return {"user": updated, "avatar_url": url}
@users_api.post("/{user_id}/avatar")
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
_require_user_admin(request)
url = await _save_avatar_file(user_id, file)
updated = auth_users.set_avatar(user_id, url)
return {"user": updated, "avatar_url": url}
@users_api.delete("/{user_id}/avatar")
async def delete_user_avatar(request: Request, user_id: int):
actor = _require_user_admin(request)
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
updated = auth_users.set_avatar(user_id, None)
return {"user": updated}
+73 -4
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
@@ -32,8 +32,14 @@ def _safe_sum(table: str, column: str, where: str = "") -> float:
def _briefing_payload(briefing: dict | None) -> dict:
"""Always use live DB stats; briefing text may be cached."""
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
"""Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
live_stats = serialize_stats(collect_briefing_data())
payload: dict = {
"content": None,
"stats": live_stats,
"live_digest": build_live_digest(live_stats),
"created_at": None,
}
if not briefing:
return payload
@@ -145,7 +151,7 @@ async def dashboard(request: Request):
"dashboard.html",
{
"request": request,
"page_title": "Herman · Command Center",
"page_title": "CEO Dashboard",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
@@ -158,6 +164,69 @@ async def dashboard(request: Request):
)
@router.get("/cto")
async def cto_redirect():
return RedirectResponse(url="/ops", status_code=302)
@router.get("/cto/dashboard")
async def cto_dashboard_legacy(request: Request):
kpis = {
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"browser_sessions_24h": 0,
}
try:
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
except Exception:
pass
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at
FROM browser_sessions ORDER BY created_at DESC LIMIT 8
"""
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
return templates.TemplateResponse(
"cto-dashboard.html",
{
"request": request,
"page_title": "CTO Dashboard",
"kpis": kpis,
"agent_feed": agent_feed,
"browser_sessions": browser_sessions,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
@router.get("/trust")
async def trust_page(request: Request):
return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"})
+2 -1
View File
@@ -73,7 +73,8 @@ async def documents_page(request: Request):
SELECT filename, storage_path, doc_type, language, word_count,
unique_lemmas, sentiment_label, sentiment_compound,
sentiment_positive, sentiment_negative, sentiment_neutral,
extraction_method, analyzed_at
extraction_method, analyzed_at,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT 50
+331
View File
@@ -0,0 +1,331 @@
"""Export Intel Cockpit page + API proxy."""
from __future__ import annotations
import os
import time
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Query, Request
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter()
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
_TILE_CACHE: dict[str, tuple[float, bytes]] = {}
_TILE_CACHE_TTL = 86400.0
_TILE_CACHE_MAX = 900
_BUNDLE_CACHE: dict[str, tuple[float, bytes]] = {}
_BUNDLE_CACHE_TTL = 120.0
async def _proxy(method: str, path: str, **kwargs) -> Any:
url = f"{TOOLS}/export-intel{path}"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.request(method, url, **kwargs)
if r.status_code >= 400:
return {"error": r.text, "status": r.status_code}
if "text/csv" in r.headers.get("content-type", ""):
return r
return r.json()
@router.get("/foodlinkk")
def foodlinkk_home():
return RedirectResponse(url="/export-intel", status_code=302)
@router.get("/export-intel")
def export_intel_page(request: Request):
return templates.TemplateResponse(
"export_intel.html",
{"request": request, "page_title": "Wereldexport"},
)
@router.get("/api/export-intel/stats")
async def api_stats(country: Optional[str] = None, region: Optional[str] = None):
params = {k: v for k, v in {"country": country, "region": region}.items() if v}
return await _proxy("GET", "/stats", params=params)
@router.get("/api/export-intel/regions")
async def api_regions():
return await _proxy("GET", "/regions")
@router.get("/api/export-intel/territories")
async def api_territories(region: Optional[str] = None):
params = {"region": region} if region else {}
return await _proxy("GET", "/territories", params=params)
@router.get("/api/export-intel/entities")
async def api_entities(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
entity_types: Optional[str] = None,
q: Optional[str] = None,
favorite_only: bool = False,
crm_linked: Optional[bool] = None,
limit: int = 200,
offset: int = 0,
):
params = {k: v for k, v in {
"country": country, "region": region, "entity_type": entity_type,
"entity_types": entity_types, "q": q, "limit": limit, "offset": offset,
"favorite_only": favorite_only, "crm_linked": crm_linked,
}.items() if v is not None and v is not False}
return await _proxy("GET", "/entities", params=params)
@router.patch("/api/export-intel/entities/{entity_id}")
async def api_update_entity(entity_id: int, request: Request):
body = await request.json()
return await _proxy("PATCH", f"/entities/{entity_id}", json=body)
@router.get("/api/export-intel/entities/{entity_id}")
async def api_entity(entity_id: int):
return await _proxy("GET", f"/entities/{entity_id}")
@router.get("/api/export-intel/contacts")
async def api_contacts(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
has_email: Optional[bool] = None,
crm_linked: Optional[bool] = None,
q: Optional[str] = None,
limit: int = 200,
offset: int = 0,
):
params: dict[str, Any] = {"limit": limit, "offset": offset}
if country:
params["country"] = country
if region:
params["region"] = region
if entity_type:
params["entity_type"] = entity_type
if has_email is not None:
params["has_email"] = has_email
if crm_linked is not None:
params["crm_linked"] = crm_linked
if q:
params["q"] = q
return await _proxy("GET", "/contacts", params=params)
@router.get("/api/export-intel/contacts/export.csv")
async def api_contacts_export(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
has_email: Optional[bool] = None,
crm_linked: Optional[bool] = None,
q: Optional[str] = None,
):
params = {
k: v
for k, v in {
"country": country,
"region": region,
"entity_type": entity_type,
"has_email": has_email,
"crm_linked": crm_linked,
"q": q,
}.items()
if v is not None and v != ""
}
url = f"{TOOLS}/export-intel/contacts/export.csv"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(url, params=params)
return StreamingResponse(
iter([r.content]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=export-intel-contacts.csv"},
)
@router.get("/api/export-intel/caterers/brands")
async def api_caterer_brands():
return await _proxy("GET", "/caterers/brands")
@router.get("/api/export-intel/caterers/presence")
async def api_caterer_presence(country: Optional[str] = None):
params = {"country": country} if country else {}
return await _proxy("GET", "/caterers/presence", params=params)
@router.get("/api/export-intel/gov-sources")
async def api_gov_sources(country: Optional[str] = None):
params = {"country": country} if country else {}
return await _proxy("GET", "/gov-sources", params=params)
@router.get("/api/map/tiles/{z}/{x}/{y}.png")
async def map_tile_proxy(z: int, x: int, y: int) -> Response:
"""Proxy Carto dark basemap tiles via cockpit (cached, no client internet needed)."""
key = f"{z}/{x}/{y}"
hit = _TILE_CACHE.get(key)
if hit and time.time() - hit[0] < _TILE_CACHE_TTL:
return Response(content=hit[1], media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
host = ("a", "b", "c", "d")[(x + y) % 4]
url = f"https://{host}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
try:
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
r = await client.get(url)
if r.status_code != 200:
return Response(status_code=502, content=b"")
body = r.content
if len(_TILE_CACHE) >= _TILE_CACHE_MAX:
oldest = min(_TILE_CACHE.items(), key=lambda item: item[1][0])[0]
_TILE_CACHE.pop(oldest, None)
_TILE_CACHE[key] = (time.time(), body)
return Response(content=body, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
except Exception:
return Response(status_code=502, content=b"")
@router.get("/api/export-intel/map/bundle")
async def api_map_bundle(
country: Optional[str] = None,
region: Optional[str] = None,
entity_type: Optional[str] = None,
entity_types: Optional[str] = None,
q: Optional[str] = None,
halal_min: Optional[float] = None,
favorite_only: bool = False,
crm_linked: Optional[bool] = None,
):
params = {k: v for k, v in {
"country": country, "region": region, "entity_type": entity_type,
"entity_types": entity_types, "q": q, "halal_min": halal_min,
"favorite_only": favorite_only, "crm_linked": crm_linked,
}.items() if v is not None and v is not False}
import hashlib
import json
cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
hit = _BUNDLE_CACHE.get(cache_key)
if hit and time.time() - hit[0] < _BUNDLE_CACHE_TTL:
return Response(content=hit[1], media_type="application/json")
url = f"{TOOLS}/export-intel/map/bundle"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(url, params=params)
if r.status_code >= 400:
return {"error": r.text, "status": r.status_code}
body = r.content
if len(_BUNDLE_CACHE) > 32:
oldest = min(_BUNDLE_CACHE.items(), key=lambda item: item[1][0])[0]
_BUNDLE_CACHE.pop(oldest, None)
_BUNDLE_CACHE[cache_key] = (time.time(), body)
return Response(content=body, media_type="application/json")
@router.post("/api/export-intel/entities/favorites")
async def api_set_favorites(request: Request):
body = await request.json()
return await _proxy("POST", "/entities/favorites", json=body)
@router.get("/api/export-intel/entities/favorites")
async def api_list_favorites(country: Optional[str] = None, region: Optional[str] = None, limit: int = 200):
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/entities/favorites", params=params)
@router.post("/api/export-intel/crm/push")
async def api_crm_push(request: Request):
body = await request.json()
return await _proxy("POST", "/crm/push", json=body)
@router.get("/api/export-intel/crm/pipeline")
async def api_crm_pipeline(country: Optional[str] = None, region: Optional[str] = None, limit: int = 100):
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/crm/pipeline", params=params)
@router.get("/api/export-intel/halal/markets")
async def api_halal_markets(
region: Optional[str] = None,
country: Optional[str] = None,
limit: int = 30,
):
params = {k: v for k, v in {"region": region, "country": country, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/halal/markets", params=params)
@router.get("/api/export-intel/tenders")
async def api_tenders(country: Optional[str] = None, status: Optional[str] = "open", limit: int = 100):
params = {k: v for k, v in {"country": country, "status": status, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/tenders", params=params)
@router.get("/api/export-intel/settings")
async def api_export_intel_settings_get():
return await _proxy("GET", "/settings")
@router.put("/api/export-intel/settings")
async def api_export_intel_settings_put(request: Request):
body = await request.json()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.put(f"{TOOLS}/export-intel/settings", json=body)
return r.json()
@router.post("/api/export-intel/sync/places")
async def api_sync_places(request: Request):
body = {}
if request.headers.get("content-type", "").startswith("application/json"):
try:
body = await request.json()
except Exception:
body = {}
async with httpx.AsyncClient(timeout=7200.0) as client:
r = await client.post(f"{TOOLS}/export-intel/sync/places", json=body)
return r.json()
@router.post("/api/export-intel/sync/cancel")
async def api_sync_cancel():
return await _proxy("POST", "/sync/cancel")
@router.get("/api/export-intel/sync/status")
async def api_sync_status():
return await _proxy("GET", "/sync/status")
@router.post("/api/export-intel/sync/{kind}")
async def api_sync(kind: str, request: Request):
body = {}
if request.headers.get("content-type", "").startswith("application/json"):
try:
body = await request.json()
except Exception:
body = {}
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region", "missing", "places"):
return {"error": "unknown sync kind"}
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
async with httpx.AsyncClient(timeout=timeout) as client:
url = f"{TOOLS}/export-intel/sync/{kind}"
r = await client.post(url, json=body)
return r.json()
+3 -3
View File
@@ -22,7 +22,7 @@ async def herman_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -38,7 +38,7 @@ async def herman_chat(request: Request, message: str = Form(...)):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -62,7 +62,7 @@ async def herman_briefing_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
+11
View File
@@ -34,6 +34,17 @@ def register_recommendation_routes(router: APIRouter) -> None:
async def reco_dismiss(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/dismiss")
@router.get("/recommendations/halal/live")
async def halal_reco_live(limit: int = 5, refresh: bool = False):
q = f"/recommendations/halal/live?limit={limit}"
if refresh:
q += "&refresh=true"
return await _proxy("GET", q)
@router.post("/recommendations/halal/refresh")
async def halal_reco_refresh(limit: int = 5):
return await _proxy("POST", f"/recommendations/halal/refresh?limit={limit}")
@router.post("/research/run")
async def research_run():
return await _proxy("POST", "/research/run")
+230
View File
@@ -0,0 +1,230 @@
"""Revenue Cockpit — page + API."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services import revenue_cockpit as svc
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
router = APIRouter(tags=["revenue-cockpit"])
api = APIRouter(prefix="/api/revenue-cockpit", tags=["revenue-cockpit-api"])
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
margin_month: Optional[float] = None
margin_year: Optional[float] = None
target_revenue: Optional[float] = None
next_steps: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
row_style: Optional[str] = None
class GoalsUpdateBody(BaseModel):
vision_text: Optional[str] = None
horizon_text: Optional[str] = None
mid_text: Optional[str] = None
tagline: Optional[str] = None
class ObjectiveBody(BaseModel):
title: str = Field(..., min_length=1)
description: Optional[str] = None
priority: str = "normal"
due_date: Optional[str] = None
class ObjectiveUpdateBody(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[str] = None
class AssignTaskBody(BaseModel):
agent_name: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
description: Optional[str] = None
objective_id: Optional[int] = None
priority: str = "normal"
delegate_herman: bool = True
class DelegateBody(BaseModel):
message: str = Field(..., min_length=1)
class ImportBody(BaseModel):
path: str = "Succes Sheet .xlsx"
sheet: Optional[str] = "Projects next steps revenue"
replace: bool = True
@router.get("/revenue-cockpit", response_class=HTMLResponse)
async def revenue_cockpit_page(request: Request):
return templates.TemplateResponse(
"revenue_cockpit.html",
{"request": request, "page_title": "Revenue Cockpit"},
)
@api.get("/live")
async def live_from_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
"""Leading data source: fresh parse from NAS Excel."""
try:
parsed = await svc.fetch_excel_parse_async(path, sheet)
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
projects = parsed.get("projects") or []
margin_month = sum(p.get("margin_month") or 0 for p in projects)
margin_year = sum(p.get("margin_year") or 0 for p in projects)
by_style: dict[str, int] = {}
by_category: dict[str, int] = {}
for p in projects:
by_style[p.get("row_style") or "white"] = by_style.get(p.get("row_style") or "white", 0) + 1
by_category[p.get("category") or "deal"] = by_category.get(p.get("category") or "deal", 0) + 1
return {
"ok": True,
"source": "excel",
**parsed,
"aggregates": {
"total_margin_month": margin_month,
"total_margin_year": margin_year,
"with_margin": sum(1 for p in projects if p.get("margin_month")),
"by_style": by_style,
"by_category": by_category,
},
}
@api.get("/dashboard")
async def dashboard():
return {"ok": True, "stats": svc.dashboard_stats(), "projects": svc.list_projects()}
@api.get("/projects")
def list_projects(status: Optional[str] = None):
return {"ok": True, "items": svc.list_projects(status)}
@api.get("/projects/{project_id}")
def get_project(project_id: int):
p = svc.get_project(project_id)
if not p:
raise HTTPException(404, "Project not found")
return {"ok": True, "project": p}
@api.patch("/projects/{project_id}")
def patch_project(project_id: int, body: ProjectUpdateBody):
data = body.model_dump(exclude_unset=True)
row_style = data.pop("row_style", None)
if row_style is not None:
svc.set_project_row_style(project_id, row_style)
p = svc.update_project(project_id, data)
if not p:
raise HTTPException(404, "Project not found")
svc.take_snapshot()
return {"ok": True, "project": p}
@api.patch("/goals")
def patch_goals(body: GoalsUpdateBody):
data = body.model_dump(exclude_unset=True)
g = svc.update_goals(data)
if not g:
raise HTTPException(404, "Goals not found")
return {"ok": True, "goals": g}
@api.post("/projects/{project_id}/objectives")
def add_objective(project_id: int, body: ObjectiveBody):
try:
obj = svc.create_objective(project_id, body.title, body.description, body.priority)
except Exception as exc:
raise HTTPException(400, str(exc)) from exc
svc.take_snapshot()
return {"ok": True, "objective": obj}
@api.patch("/objectives/{objective_id}")
def patch_objective(objective_id: int, body: ObjectiveUpdateBody):
data = body.model_dump(exclude_unset=True)
obj = svc.update_objective(objective_id, data)
if not obj:
raise HTTPException(404, "Objective not found")
svc.take_snapshot()
return {"ok": True, "objective": obj}
@api.post("/projects/{project_id}/assign-task")
def assign_task(project_id: int, body: AssignTaskBody):
try:
task = svc.assign_agent_task(
project_id,
body.agent_name,
body.title,
body.description,
body.objective_id,
body.priority,
body.delegate_herman,
)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
svc.take_snapshot()
return {"ok": True, "task": task}
@api.post("/projects/{project_id}/delegate")
async def delegate_project(project_id: int, body: DelegateBody):
try:
result = await svc.delegate_via_herman(project_id, body.message)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return result
@api.get("/tasks")
def list_tasks(limit: int = 50):
return {"ok": True, "items": svc.list_agent_tasks(limit)}
@api.get("/snapshots")
def snapshots(limit: int = 90):
return {"ok": True, "items": svc.list_snapshots(limit)}
@api.post("/snapshot")
def create_snapshot():
snap = svc.take_snapshot()
return {"ok": True, "snapshot": snap}
@api.post("/import-from-excel")
async def import_from_excel(body: ImportBody):
try:
parsed = await svc.fetch_excel_parse_async(body.path, body.sheet)
result = svc.import_from_parsed(parsed, imported_by="ceo", replace=body.replace)
return {"ok": True, **result, "preview": {"project_count": parsed.get("project_count"), "sheet": parsed.get("sheet_name")}}
except Exception as exc:
raise HTTPException(502, f"Import failed: {exc}") from exc
@api.get("/preview-excel")
async def preview_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
try:
parsed = await svc.fetch_excel_parse_async(path, sheet)
return {"ok": True, **parsed}
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
+13 -2
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app import auth_users
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -10,12 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
allowed = ("email", "general", "themes", "permissions", "social", "users")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
user = getattr(request.state, "user", None)
if active == "users" and not auth_users.can_manage_users(user):
return RedirectResponse("/settings?tab=email", status_code=303)
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": tab if tab in allowed else "email",
"active_tab": active,
"can_manage_users": auth_users.can_manage_users(user),
"current_user": user,
},
)
+116
View File
@@ -381,3 +381,119 @@ def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
# ── LLM providers ──────────────────────────────────────────────────────────
from app.services import llm_router
class LlmProviderBody(BaseModel):
label: str = Field(..., max_length=128)
provider_type: str = Field(default="deepseek", max_length=48)
api_base_url: Optional[str] = None
api_key: Optional[str] = None
model: str = Field(default="deepseek-chat", max_length=128)
is_active: bool = True
is_default: bool = False
extra_config: dict[str, Any] = Field(default_factory=dict)
@settings_router.get("/llm/presets")
def llm_presets() -> dict[str, Any]:
return {"presets": llm_router.list_presets()}
@settings_router.get("/llm")
def llm_list() -> dict[str, Any]:
llm_router.ensure_openrouter_placeholder()
items = llm_router.list_providers()
default = next((i for i in items if i.get("is_default")), items[0] if items else None)
return {"providers": items, "default": default, "presets": llm_router.list_presets()}
@settings_router.post("/llm")
def llm_create(body: LlmProviderBody) -> dict[str, Any]:
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
model = body.model or preset.get("default_model") or (preset.get("models") or ["deepseek-chat"])[0]
if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one(
"""INSERT INTO llm_providers (
label, provider_type, api_base_url, api_key, model,
is_active, is_default, extra_config, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
RETURNING *""",
(
body.label,
body.provider_type,
base_url,
body.api_key or "",
model,
body.is_active,
body.is_default,
json.dumps(body.extra_config or {}),
),
)
return {"provider": llm_router._mask_provider(row)}
@settings_router.put("/llm/{provider_id}")
def llm_update(provider_id: int, body: LlmProviderBody) -> dict[str, Any]:
existing = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
if not existing:
raise HTTPException(404, "Provider niet gevonden")
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
base_url = (body.api_base_url or preset.get("api_base_url") or existing.get("api_base_url") or "").strip() or None
api_key = body.api_key if body.api_key else existing.get("api_key") or ""
if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one(
"""UPDATE llm_providers SET
label = %s, provider_type = %s, api_base_url = %s, api_key = %s, model = %s,
is_active = %s, is_default = %s, extra_config = %s::jsonb, updated_at = NOW()
WHERE id = %s RETURNING *""",
(
body.label,
body.provider_type,
base_url,
api_key,
body.model,
body.is_active,
body.is_default,
json.dumps(body.extra_config or {}),
provider_id,
),
)
return {"provider": llm_router._mask_provider(row)}
@settings_router.delete("/llm/{provider_id}")
def llm_delete(provider_id: int) -> dict[str, Any]:
row = fetch_one("SELECT is_default FROM llm_providers WHERE id = %s", (provider_id,))
if not row:
raise HTTPException(404, "Provider niet gevonden")
execute("DELETE FROM llm_providers WHERE id = %s", (provider_id,))
if row.get("is_default"):
execute(
"""UPDATE llm_providers SET is_default = TRUE, updated_at = NOW()
WHERE id = (SELECT id FROM llm_providers ORDER BY id LIMIT 1)"""
)
return {"ok": True}
@settings_router.post("/llm/{provider_id}/activate")
def llm_activate(provider_id: int) -> dict[str, Any]:
llm_router.set_default(provider_id)
row = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
return {"ok": True, "provider": llm_router._mask_provider(row)}
@settings_router.post("/llm/{provider_id}/test")
async def llm_test(provider_id: int) -> dict[str, Any]:
ok, msg = await llm_router.test_provider(provider_id)
if not ok:
raise HTTPException(502, msg)
return {"ok": True, "message": msg}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -191,3 +191,48 @@ def require_approved(request_id: int) -> dict[str, Any]:
if req.get("status") != "approved":
raise PermissionError(f"Request status is {req.get('status')}, approval required")
return req
def _auto_approve_policy() -> dict[str, bool]:
try:
row = fetch_one("SELECT value FROM app_settings WHERE key = 'sysops_auto_approve'")
if row and row.get("value"):
val = row["value"]
if isinstance(val, str):
return json.loads(val)
return dict(val)
except Exception:
pass
return {"maintenance_scan": True, "config_backup": True}
def try_auto_approve_and_execute(request_id: int) -> dict[str, Any] | None:
"""Auto-approve low-risk SysOps requests when policy allows."""
req = get_request(request_id)
if not req or req.get("status") != "pending":
return None
key = str(req.get("agent_key") or "").lower()
action = str(req.get("action_type") or "")
policy = _auto_approve_policy()
if key != "sysops" or not policy.get(action):
return None
approved = approve_request(request_id, approved_by="auto_policy")
import os
import httpx
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
result_payload: dict[str, Any] = {}
try:
if action == "config_backup":
with httpx.Client(timeout=180.0) as client:
resp = client.post(f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id})
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
elif action == "maintenance_scan":
with httpx.Client(timeout=120.0) as client:
resp = client.post(f"{tools_url}/ops/maintenance/scan")
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
except Exception as exc:
result_payload = {"ok": False, "detail": str(exc)}
executed = mark_executed(request_id, result_payload)
return {"approved": approved, "executed": executed, "auto": True}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
"""Log agent activity to agent_events for terminals and audit."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import fetch_one
from app.services.agent_names import normalize_agent_key
def log_agent_event(
agent_name: str,
event_type: str,
title: str,
body: str = "",
*,
agent_type: Optional[str] = None,
status: str = "completed",
channel: str = "cockpit",
metadata: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
key = normalize_agent_key(agent_name)
if not key:
return None
meta = json.dumps(metadata or {})
row = fetch_one(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, agent_name, event_type, title, body, status, channel, metadata, created_at
""",
(
key,
agent_type or key,
event_type,
title[:255],
(body or "")[:8000],
status,
channel,
meta,
),
)
if not row:
return None
out = dict(row)
if out.get("created_at") and hasattr(out["created_at"], "isoformat"):
out["created_at"] = out["created_at"].isoformat()
return out
+145
View File
@@ -0,0 +1,145 @@
"""Agent handoffs and collaboration matrix."""
from __future__ import annotations
import json
import uuid
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services.agent_names import normalize_agent_key
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
elif k == "correlation_id" and v is not None:
out[k] = str(v)
return out
def list_collaboration() -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT from_agent, to_agent, handoff_type, description FROM agent_collaboration ORDER BY from_agent, to_agent"
)
return [dict(r) for r in rows]
def create_handoff(
from_agent: str,
to_agent: str,
*,
handoff_type: str = "partner",
payload: dict[str, Any] | None = None,
correlation_id: str | None = None,
status: str = "completed",
) -> dict[str, Any]:
src = normalize_agent_key(from_agent)
dst = normalize_agent_key(to_agent)
if not src or not dst:
raise ValueError("from_agent and to_agent required")
cid = correlation_id or str(uuid.uuid4())
try:
uuid.UUID(str(cid))
except ValueError:
cid = str(uuid.uuid4())
row = fetch_one(
"""
INSERT INTO agent_handoffs (correlation_id, from_agent, to_agent, handoff_type, payload, status, completed_at)
VALUES (%s::uuid, %s, %s, %s, %s::jsonb, %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END)
RETURNING *
""",
(cid, src, dst, handoff_type, json.dumps(payload or {}), status, status),
)
handoff = _serialize(row) or {}
meta = {
"correlation_id": cid,
"handoff_id": handoff.get("id"),
"target_agent": dst,
"source_agent": src,
"handoff_type": handoff_type,
}
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
try:
from app.services import agent_chat
agent_chat.announce_handoff(src, dst, handoff_type, correlation_id=cid)
except Exception:
pass
return handoff
def _log_handoff_events(
src: str,
dst: str,
handoff_type: str,
payload: dict[str, Any],
meta: dict[str, Any],
cid: str,
) -> None:
title_out = f"{src} → {dst}: {handoff_type}"
title_in = f"Handoff van {src}: {handoff_type}"
body = json.dumps(payload)[:2000] if payload else ""
for agent, etype, title, extra in (
(src, "handoff_out", title_out, {"target_agent": dst}),
(dst, "handoff_in", title_in, {"source_agent": src}),
):
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
agent,
"agent_handoff",
etype,
title[:255],
body,
"completed",
"agents",
json.dumps({**meta, **extra}),
),
)
except Exception:
pass
def recent_handoffs(hours: int = 6) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT * FROM agent_handoffs
WHERE created_at >= NOW() - make_interval(hours => %s)
ORDER BY created_at DESC
LIMIT 200
""",
(max(1, min(hours, 168)),),
)
return [_serialize(r) for r in rows if r]
def peer_edges_live(hours: int = 6) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT from_agent, to_agent, handoff_type, COUNT(*) AS weight,
MAX(correlation_id::text) AS correlation_id
FROM agent_handoffs
WHERE created_at >= NOW() - make_interval(hours => %s)
AND status = 'completed'
GROUP BY from_agent, to_agent, handoff_type
""",
(max(1, min(hours, 168)),),
)
return [
{
"source": str(r["from_agent"]),
"target": str(r["to_agent"]),
"type": "live",
"handoff_type": r.get("handoff_type"),
"weight": int(r["weight"] or 1),
"correlation_id": r.get("correlation_id"),
}
for r in rows
]
+40
View File
@@ -0,0 +1,40 @@
"""Normalize legacy agent_name values to agent_souls keys."""
from __future__ import annotations
AGENT_NAME_MAP: dict[str, str] = {
"retail_360": "retail",
"retail_scraper": "retail",
"retail_crm": "retail",
"retail_intel": "retail",
"rss_feeds": "marketing",
"wholesale_scraper": "sourcing",
"halal_registry": "halal",
"halal_engine": "halal",
"halal_reco": "halal",
"branch_scraper": "sourcing",
"hermes": "herman",
"herman_delegate": "herman",
"ops": "sysops",
"sysop": "sysops",
"web_builder": "webbuilder",
"packaging_agent": "packaging",
}
def normalize_agent_key(name: str | None) -> str:
key = (name or "").strip().lower()
if not key:
return ""
return AGENT_NAME_MAP.get(key, key)
def aliases_for_key(canonical: str) -> list[str]:
"""All raw agent_name values that map to this soul key (including the key itself)."""
key = (canonical or "").strip().lower()
if not key:
return []
names = {key}
for raw, canon in AGENT_NAME_MAP.items():
if canon == key:
names.add(raw)
return sorted(names)
+139
View File
@@ -0,0 +1,139 @@
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
def slugify(name: str, max_len: int = 40) -> str:
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
return (s[:max_len] or "file")
def root() -> Path:
return AGENTS_NAS_ROOT
def unc() -> str:
return AGENTS_NAS_UNC
def smb() -> str:
return AGENTS_NAS_SMB
def ensure_root() -> Path:
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
sys_dir = AGENTS_NAS_ROOT / "_system"
sys_dir.mkdir(parents=True, exist_ok=True)
readme = sys_dir / "README.txt"
if not readme.exists():
readme.write_text(
"Foodlinkk Agents NAS workspace\n"
f"Pad: {AGENTS_NAS_SMB}\n"
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
"Submappen: notes, exports, memory, inbox, outbox\n",
encoding="utf-8",
)
meta = {
"unc": AGENTS_NAS_UNC,
"smb": AGENTS_NAS_SMB,
"container_path": str(AGENTS_NAS_ROOT),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
(sys_dir / "workspace.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
)
return AGENTS_NAS_ROOT
def ensure_agent_folder(agent_key: str) -> Path:
ensure_root()
key = slugify(agent_key or "unknown", 32).lower()
base = AGENTS_NAS_ROOT / key
for sub in AGENT_SUBDIRS:
(base / sub).mkdir(parents=True, exist_ok=True)
meta = {
"agent_key": key,
"path": str(base),
"smb": f"{AGENTS_NAS_SMB}/{key}",
"subdirs": list(AGENT_SUBDIRS),
}
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
return base
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
ensure_root()
keys = agent_keys or []
created = []
for k in keys:
p = ensure_agent_folder(str(k))
created.append(str(p))
return {
"ok": True,
"root": str(AGENTS_NAS_ROOT),
"smb": AGENTS_NAS_SMB,
"agents": created,
"count": len(created),
}
def memory_rule_text() -> str:
return (
"NAS DATA (HARDCODED — VERPLICHT):\n"
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
"inbox/ (inkomend), outbox/ (uitgaand).\n"
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
)
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
base = ensure_agent_folder(agent_key)
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
rel = relative_path.lstrip("/\\").replace("..", "")
if not rel:
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
target = (base / sub / rel).resolve()
if not str(target).startswith(str(base.resolve())):
raise ValueError("Pad buiten agent-map niet toegestaan")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
key = slugify(agent_key).lower()
return {
"ok": True,
"path": str(target),
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
}
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
return write_text(
agent_key,
name,
json.dumps(data, indent=2, ensure_ascii=False, default=str),
subdir=subdir,
)
def agent_paths(agent_key: str) -> dict[str, str]:
key = slugify(agent_key or "unknown", 32).lower()
return {
"root": str(AGENTS_NAS_ROOT / key),
"smb": f"{AGENTS_NAS_SMB}/{key}",
}
+141 -21
View File
@@ -1,49 +1,169 @@
"""Agent soul profiles and Herman permissions."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services.agent_names import aliases_for_key, normalize_agent_key
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
if events_6h > 0 and errors_24h == 0:
return "healthy"
if events_6h > 0:
return "warn"
if event_count > 0:
return "idle"
return "offline"
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
rows = fetch_all(
"""
SELECT agent_name, title, status, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '30 days'
ORDER BY created_at DESC
"""
)
now = datetime.now(timezone.utc)
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
key = normalize_agent_key(str(r.get("agent_name") or ""))
if not key:
continue
bucket = by_key.setdefault(
key,
{
"event_count": 0,
"events_6h": 0,
"events_24h": 0,
"errors_24h": 0,
"last_event_at": None,
"last_event_title": None,
"current_status": None,
"current_event_type": None,
},
)
bucket["event_count"] += 1
created = r.get("created_at")
if bucket["last_event_at"] is None:
bucket["last_event_at"] = created
bucket["last_event_title"] = r.get("title")
bucket["current_status"] = r.get("status")
if created is not None:
if hasattr(created, "tzinfo") and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age_ok = True
try:
if created >= now - timedelta(hours=6):
bucket["events_6h"] += 1
if created >= now - timedelta(hours=24):
bucket["events_24h"] += 1
if str(r.get("status") or "") in ("error", "rejected"):
bucket["errors_24h"] += 1
except Exception:
age_ok = False
_ = age_ok
# Also count older totals beyond 30d window lightly
try:
totals = fetch_all(
"""
SELECT agent_name, COUNT(*)::int AS c
FROM agent_events
GROUP BY agent_name
"""
)
total_by: dict[str, int] = {}
for r in totals:
k = normalize_agent_key(str(r.get("agent_name") or ""))
if not k:
continue
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
for k, c in total_by.items():
bucket = by_key.setdefault(
k,
{
"event_count": 0,
"events_6h": 0,
"events_24h": 0,
"errors_24h": 0,
"last_event_at": None,
"last_event_title": None,
"current_status": None,
"current_event_type": None,
},
)
bucket["event_count"] = c
except Exception:
pass
return by_key
def list_souls() -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_task,
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_status,
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_event_type
FROM agent_souls s ORDER BY s.display_name"""
rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name")
stats = _aggregate_event_stats()
out: list[dict[str, Any]] = []
for r in rows:
soul = dict(r)
key = str(soul.get("agent_key") or "").lower()
st = stats.get(key, {})
events_6h = int(st.get("events_6h") or 0)
errors_24h = int(st.get("errors_24h") or 0)
event_count = int(st.get("event_count") or 0)
health = _node_health(events_6h, errors_24h, event_count)
lat = st.get("last_event_at")
if lat is not None and hasattr(lat, "isoformat"):
lat = lat.isoformat()
soul["event_count"] = event_count
soul["events_6h"] = events_6h
soul["events_24h"] = int(st.get("events_24h") or 0)
soul["errors_24h"] = errors_24h
soul["health"] = health
soul["last_event_at"] = lat
soul["current_task"] = st.get("last_event_title") or soul.get("current_task")
soul["current_status"] = st.get("current_status")
# Working = recent activity (not just DB is_active flag)
soul["working"] = events_6h > 0
# is_active souls are never "offline" in the registry (War Room / mesh ready)
if not soul.get("is_active", True):
soul["activity"] = "offline"
else:
soul["activity"] = (
"busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5)
else ("active" if events_6h > 0 else "idle")
)
return [dict(r) for r in rows]
out.append(soul)
return out
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count
FROM agent_souls s WHERE agent_key = %s""",
(agent_key.lower(),),
)
row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),))
if not row:
return None
out = dict(row)
key = agent_key.lower()
stats = _aggregate_event_stats().get(key, {})
out["event_count"] = int(stats.get("event_count") or 0)
out["events_6h"] = int(stats.get("events_6h") or 0)
out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"])
out["recent_events"] = list_agent_events(agent_key, limit=15)
return out
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
names = aliases_for_key(agent_key)
if not names:
return []
placeholders = ",".join(["%s"] * len(names))
rows = fetch_all(
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
FROM agent_events
WHERE LOWER(agent_name) = %s
WHERE LOWER(agent_name) IN ({placeholders})
ORDER BY created_at DESC
LIMIT %s""",
(agent_key.lower(), limit),
(*[n.lower() for n in names], limit),
)
out: list[dict[str, Any]] = []
for row in rows:
+481
View File
@@ -0,0 +1,481 @@
"""Interactive agent terminal — parse and execute whitelisted commands."""
from __future__ import annotations
import json
import os
from typing import Any, Optional
import httpx
from app.db import fetch_all, fetch_one
from app.services import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent
from app.services.agent_events_log import log_agent_event
from app.services.agent_names import normalize_agent_key
TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
Line = dict[str, Any]
GLOBAL_HELP = [
"help — dit overzicht",
"status — huidige status & taak",
"history — laatste events (in terminal)",
"handoff <agent> <type> [notitie]",
"say <bericht> — log een notitie",
"ask <vraag> — AI-antwoord in rol van deze agent",
"nas — toon Agents NAS-pad",
"save <bestand> <tekst> — schrijf naar NAS Agents/<agent>/notes/",
]
AGENT_HELP: dict[str, list[str]] = {
"herman": [
"briefing — dagrapport genereren",
"<tekst> — vraag / opdracht aan Herman",
],
"browser": [
"monitor add <url> — site toevoegen aan monitor",
"monitor list — actieve sites",
"browse <url> — pagina openen",
"crawl — alle sites crawlen + parse-overzicht",
"parse — laatste parse-resultaten",
"parse <id> — parse van specifieke site",
"intel — hype & trend analyse (samenvatting)",
"intel <zoekterm> — filter analyse",
],
"research": ["run — research pipeline starten", "briefs — recente briefs"],
"retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
"sysops": [
"backup — config backup (approval)",
"scan — maintenance scan",
"topology — infra overzicht",
"status — sysops status",
],
"marketing": ["reco — aanbevelingen genereren"],
"packaging": ["design <brief> — packaging opdracht (via Herman)"],
"webbuilder": [
"build <opdracht> — website bouwen (agy op Hermes)",
"projects — bestaande website-projecten",
"status [project] — build-status",
"preview [project] — preview-URL",
],
"email": ["sync — inbox sync trigger"],
"finance": ["margins — marge-overzicht (placeholder)"],
}
def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
return {"type": line_type, "message": msg, "detail": detail}
def _help_lines(key: str) -> list[Line]:
lines = [_line("Beschikbare commando's:", line_type="output")]
for h in GLOBAL_HELP:
lines.append(_line(" " + h, line_type="output"))
extra = AGENT_HELP.get(key, [])
if extra:
lines.append(_line(f"— {key} —", line_type="output"))
for h in extra:
lines.append(_line(" " + h, line_type="output"))
return lines
def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
recent = soul.get("recent_events") or []
last = recent[0] if recent else {}
lines = [
_line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
_line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
_line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
_line(f"Events totaal: {soul.get('event_count') or 0}"),
]
resp = (soul.get("responsibilities") or "").strip()
if resp:
lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
return lines
async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
data.setdefault("detail", resp.text[:500])
return data
async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{TOOLS_URL}{path}")
try:
return resp.json()
except Exception:
return {"ok": False, "detail": resp.text[:500]}
async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
try:
return resp.json()
except Exception:
return {"ok": False, "detail": resp.text[:500]}
def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
dst = normalize_agent_key(dst_raw)
if not dst:
return [_line("Onbekende agent: " + dst_raw, line_type="error")]
payload = {"note": note} if note else {}
try:
agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
except Exception as exc:
return [_line("Handoff mislukt: " + str(exc), line_type="error")]
async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
name = soul.get("display_name") or key
role = soul.get("role_title") or ""
prompt = (
f"Je bent {name} ({key}), {role}. "
f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
)
result = await herman.chat(prompt, channel=f"terminal:{key}")
reply = (result.get("reply") or "").strip()
delegated = result.get("delegated_agents") or []
lines = [_line(reply or "(geen antwoord)", detail=reply)]
if delegated:
lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
log_agent_event(
key,
"terminal_out",
reply[:255] if reply else "Antwoord",
reply[:4000],
channel="terminal",
metadata={"delegated": delegated, "question": question[:500]},
)
return lines
def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
if result.get("message") and not result.get("results"):
return [_line(result["message"], line_type="output")]
lines: list[Line] = [
_line(
f"Crawl klaar — {result.get('sites', 0)} site(s), "
f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
)
]
for r in result.get("results") or []:
if r.get("status") == "ERROR":
lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
continue
flag = "△" if r.get("changed") else "✓"
lines.append(
_line(
f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
f"{r.get('links_count', 0)} links",
detail=(r.get("excerpt") or "")[:200],
)
)
headings = r.get("headings") or []
if headings:
lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
for link in (r.get("links_sample") or [])[:3]:
lbl = link.get("label") or link.get("href") or ""
lines.append(_line(" → " + lbl[:70], line_type="output"))
return lines
def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
if not items:
return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
for p in items:
lines.append(
_line(
f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
f"{p.get('word_count', 0)} woorden",
detail=(p.get("excerpt") or "")[:200],
)
)
if p.get("meta_description"):
lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
for h in (p.get("headings") or [])[:3]:
lines.append(_line(" · " + h[:90], line_type="output"))
return lines
async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
if key == "herman":
if cmd == "briefing":
try:
content = await herman.generate_briefing()
preview = (content or "")[:400]
log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
return [_line("CEO briefing gegenereerd.", detail=preview)]
except Exception as exc:
return [_line("Briefing mislukt: " + str(exc), line_type="error")]
result = await herman.chat(raw, channel="terminal")
reply = (result.get("reply") or "").strip()
delegated = result.get("delegated_agents") or []
lines = [_line(reply or "(geen antwoord)", detail=reply)]
if delegated:
lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
return lines
if key == "browser":
if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
url = args[1]
from app.services.monitor import add_site
site = add_site(url, name=url[:80])
log_agent_event(
"browser",
"monitor_site_added",
f"Terminal: site toegevoegd {url[:120]}",
"",
channel="terminal",
metadata={"url": url, "site_id": site.get("id")},
)
return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
if cmd == "monitor" and args and args[0].lower() == "list":
rows = fetch_all(
"SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
)
if not rows:
return [_line("Geen monitor-sites.")]
return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
if cmd == "browse" and args:
url = args[0]
data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
title = data.get("title") or data.get("url") or url
return [_line(f"Browse OK: {title}")]
if cmd == "crawl":
from app.services.monitor import trigger_crawl
site_id = int(args[0]) if args and str(args[0]).isdigit() else None
result = trigger_crawl(site_id)
log_agent_event(
"browser",
"monitor_crawl",
f"Terminal crawl ({result.get('sites', 0)} sites)",
json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
channel="terminal",
)
return _format_crawl_results(result)
if cmd == "parse":
from app.services.monitor import list_parse_results
sid = int(args[0]) if args and str(args[0]).isdigit() else None
return _format_parse_items(list_parse_results(site_id=sid, limit=10))
if cmd == "intel":
from app.services.monitor import build_parse_intelligence
q = " ".join(args) if args else None
data = build_parse_intelligence(query=q, limit=20)
lines = [
_line(
f"Analyse: {data['summary']['pages']} pagina's · "
f"{data['summary']['total_words']} woorden · "
f"{data['summary']['themes_detected']} trend-termen"
),
_line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
]
for t in (data.get("hype_terms") or [])[:12]:
flag = "★ " if t.get("cross_site") else ""
lines.append(
_line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
)
return lines
if key == "research":
if cmd == "run":
data = await _tools_post("/research/run")
ok = data.get("ok", True)
log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
if cmd == "briefs":
data = await _tools_get("/research/briefs")
items = data if isinstance(data, list) else data.get("items") or []
if not items:
return [_line("Geen briefs.")]
return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
if key == "retail":
if cmd == "rss":
data = await _tools_post("/retail/rss/refresh")
log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
if cmd == "scores":
data = await _tools_post("/retail/compute-opportunities")
return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
if key == "sysops":
if cmd == "backup":
data = await _tools_post("/ops/backup/run")
log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
if cmd == "scan":
data = await _tools_post("/ops/maintenance/scan")
return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
if cmd == "topology":
data = await _tools_get("/ops/topology")
return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
if cmd == "status":
data = await _tools_get("/ops/status")
return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
if key == "marketing" and cmd == "reco":
data = await _tools_post("/recommendations/generate")
return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
if key == "packaging" and cmd == "design":
brief = " ".join(args) or raw
result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
reply = (result.get("reply") or "").strip()
return [_line(reply, detail=reply)]
if key == "webbuilder":
if cmd == "build":
brief = rest or raw
if not brief:
return [_line("Gebruik: build <opdracht>", line_type="error")]
try:
outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
proj = outcome.get("project") or "?"
preview = outcome.get("preview_url") or "—"
return [
_line(f"Build gestart: {proj}"),
_line(f"Preview: {preview}", line_type="output"),
_line("Volg voortgang live in dit terminal-venster.", line_type="output"),
]
except Exception as exc:
return [_line("Build mislukt: " + str(exc), line_type="error")]
if cmd == "projects":
data = await webbuilder_agent.list_projects()
items = data.get("items") or []
if not items:
return [_line("Geen projecten op Hermes/NAS.")]
return [
_line(
f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
+ (" · index.html ✓" if p.get("has_index") else "")
)
for p in items
]
if cmd == "status":
slug = args[0] if args else ""
if not slug:
return [_line("Gebruik: status <project>", line_type="error")]
st = await webbuilder_agent.get_build_status(slug)
lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
tail = (st.get("log_tail") or "")[:300]
if tail:
lines.append(_line(tail, line_type="output"))
return lines
if cmd == "preview":
slug = args[0] if args else ""
if slug:
st = await webbuilder_agent.get_build_status(slug)
url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
else:
url = webbuilder_agent.HERMES_PREVIEW_BASE
return [_line(f"Preview: {url}")]
return await _agent_ask(key, soul, raw)
async def execute_terminal_command(
agent_key: str,
command: str,
*,
issued_by: str = "ceo",
) -> dict[str, Any]:
key = normalize_agent_key(agent_key)
soul = agent_souls.get_soul(key)
if not soul:
raise ValueError("Agent niet gevonden")
raw = (command or "").strip()
if not raw:
raise ValueError("Leeg commando")
log_agent_event(
key,
"terminal_in",
raw[:255],
"",
channel="terminal",
metadata={"issued_by": issued_by, "command": raw[:500]},
)
parts = raw.split()
cmd = parts[0].lower()
args = parts[1:]
rest = " ".join(args)
if cmd in ("help", "?", "h"):
lines = _help_lines(key)
elif cmd == "status":
lines = _status_lines(key, soul)
elif cmd == "handoff":
if len(args) < 2:
lines = [_line("Gebruik: handoff <agent> <type> [notitie]", line_type="error")]
else:
note = " ".join(args[2:]) if len(args) > 2 else ""
lines = _handoff(key, args[0], args[1], note)
elif cmd == "nas":
try:
agent_nas.ensure_agent_folder(key)
paths = agent_nas.agent_paths(key)
lines = [
_line("Agents NAS: " + str(agent_nas.smb())),
_line("Jouw map: " + str(paths.get("smb") or "")),
_line("Container: " + str(paths.get("root") or "")),
_line("Sub: notes/ exports/ memory/ inbox/ outbox/"),
]
except Exception as exc:
lines = [_line("NAS fout: " + str(exc), line_type="error")]
elif cmd == "save":
if len(args) < 2:
lines = [_line("Gebruik: save <bestandsnaam> <tekst>", line_type="error")]
else:
fname = args[0]
body = " ".join(args[1:])
try:
result = agent_nas.write_text(key, fname, body, subdir="notes")
lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))]
except Exception as exc:
lines = [_line("Save mislukt: " + str(exc), line_type="error")]
elif cmd == "say":
if not rest:
lines = [_line("Gebruik: say <bericht>", line_type="error")]
else:
log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
lines = [_line("Genoteerd.")]
elif cmd == "ask":
if not rest:
lines = [_line("Gebruik: ask <vraag>", line_type="error")]
else:
lines = await _agent_ask(key, soul, rest)
elif cmd == "history":
events = agent_souls.list_agent_events(key, limit=8)
if not events:
lines = [_line("Geen history.")]
else:
lines = []
for ev in reversed(events):
t = (ev.get("created_at") or "")[11:19] or "--:--"
lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
else:
lines = await _run_agent_command(key, soul, cmd, args, raw)
return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
+376 -21
View File
@@ -1,10 +1,10 @@
"""Comprehensive analytics data from all DB tables with optional filters."""
"""Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all, fetch_one
from app.db import execute, fetch_all, fetch_one
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
return rows
def ensure_findings_table() -> None:
try:
execute(
"""
CREATE TABLE IF NOT EXISTS analytics_findings (
id SERIAL PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'insight',
title TEXT NOT NULL,
body TEXT,
score DOUBLE PRECISION,
source TEXT DEFAULT 'hermes',
href TEXT,
meta JSONB DEFAULT '{}'::jsonb,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
"""
)
except Exception:
pass
def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]:
ensure_findings_table()
try:
rows = fetch_all(
"""
SELECT id, kind, title, body, score, source, href, meta, created_at
FROM analytics_findings
WHERE active = TRUE
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return _iso_rows(rows or [])
except Exception:
return []
def add_finding(
*,
kind: str,
title: str,
body: str = "",
score: float | None = None,
source: str = "hermes",
href: str | None = None,
meta: dict | None = None,
) -> dict[str, Any]:
ensure_findings_table()
import json
row = fetch_one(
"""
INSERT INTO analytics_findings (kind, title, body, score, source, href, meta)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, kind, title, body, score, source, href, meta, created_at
""",
(
kind or "insight",
title,
body or "",
score,
source or "hermes",
href,
json.dumps(meta or {}),
),
)
return _iso_rows([row])[0] if row else {}
def _revenue_kpis() -> dict[str, Any]:
out = {
"active_projects": 0,
"margin_month": 0.0,
"margin_year": 0.0,
"vision_text": "",
"horizon_text": "",
"tagline": "",
}
try:
row = fetch_one(
"""
SELECT COUNT(*)::int AS c,
COALESCE(SUM(margin_month),0) AS mm,
COALESCE(SUM(margin_year),0) AS my
FROM revenue_projects
WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done')
"""
)
if row:
out["active_projects"] = int(row.get("c") or 0)
out["margin_month"] = float(row.get("mm") or 0)
out["margin_year"] = float(row.get("my") or 0)
except Exception:
pass
try:
g = fetch_one(
"""
SELECT vision_text, horizon_text, tagline, mid_text
FROM revenue_cockpit_goals
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
"""
)
if g:
out["vision_text"] = g.get("vision_text") or ""
out["horizon_text"] = g.get("horizon_text") or ""
out["tagline"] = g.get("tagline") or ""
out["mid_text"] = g.get("mid_text") or ""
except Exception:
pass
return out
def _build_pulse(data: dict[str, Any]) -> dict[str, Any]:
k = data.get("kpis") or {}
partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])}
active_p = int(partners.get("active") or k.get("crm_partnerships") or 0)
none_p = int(partners.get("none") or 0)
proposal_p = int(partners.get("proposal") or 0)
stores = int(k.get("supermarkets") or 0)
cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0
gap = max(stores - active_p - proposal_p, 0)
wins: list[dict[str, Any]] = []
opportunities: list[dict[str, Any]] = []
attention: list[dict[str, Any]] = []
if stores >= 1000:
wins.append({
"id": "win-coverage",
"title": "Retail footprint is massief",
"body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."),
"score": 92,
"metric": f"{stores} stores",
"href": "/retail",
})
if active_p >= 10:
wins.append({
"id": "win-crm",
"title": "Actieve partnerships draaien",
"body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.",
"score": 78,
"metric": f"{active_p} actief",
"href": "/retail",
})
if int(k.get("rss_items") or 0) >= 200:
wins.append({
"id": "win-rss",
"title": "Markt-signalen stromen binnen",
"body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.",
"score": 74,
"metric": f"{int(k['rss_items'])} items",
"href": "/marketing",
})
if int(k.get("promo_campaigns") or 0) >= 10:
wins.append({
"id": "win-promo",
"title": "Promo-intelligence actief",
"body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.",
"score": 70,
"metric": f"{int(k['promo_campaigns'])} campagnes",
"href": "/marketing",
})
if float(k.get("margin_month") or 0) > 0:
wins.append({
"id": "win-margin",
"title": "Revenue engine levert marge",
"body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."),
"score": 88,
"metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."),
"href": "/revenue-cockpit",
})
if int(k.get("wholesalers") or 0) >= 100:
wins.append({
"id": "win-wholesale",
"title": "Groothandel-netwerk in kaart",
"body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.",
"score": 68,
"metric": f"{int(k['wholesalers'])} GH",
"href": "/retail",
})
if int(k.get("agent_events") or 0) >= 500:
wins.append({
"id": "win-agents",
"title": "Agent-mesh is productief",
"body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.",
"score": 66,
"metric": f"{int(k['agent_events'])} events",
"href": "/agents",
})
if gap >= 100:
opportunities.append({
"id": "opp-white-space",
"title": "Enorm witruimte in retail CRM",
"body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."),
"score": min(98, 40 + int(gap / 100)),
"metric": f"{gap} open",
"href": "/retail",
"tag": "distributie",
})
if cover_pct < 5:
opportunities.append({
"id": "opp-cover",
"title": "Partnership-dekking is nog dun",
"body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.",
"score": 90,
"metric": f"{cover_pct}% dekking",
"href": "/retail",
"tag": "groei",
})
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
opportunities.append({
"id": "opp-clients",
"title": "Klanten in intake → nog niet actief",
"body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.",
"score": 82,
"metric": "0 active",
"href": "/clients",
"tag": "crm",
})
if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0:
opportunities.append({
"id": "opp-pipeline",
"title": "Deals zonder pipeline-waarde",
"body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.",
"score": 76,
"metric": "€0 pipeline",
"href": "/deals",
"tag": "sales",
})
top_opp = data.get("top_opportunities") or []
if top_opp:
best = top_opp[0]
opportunities.append({
"id": "opp-halal-top",
"title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}",
"body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.",
"score": round(float(best.get("halal_opportunity_score") or 0)),
"metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}",
"href": "/retail",
"tag": "halal",
})
# cluster cities
cities: dict[str, int] = {}
for o in top_opp:
cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1
top_city = sorted(cities.items(), key=lambda x: -x[1])[0]
if top_city[1] >= 2:
opportunities.append({
"id": "opp-city-cluster",
"title": f"Cluster-kans in {top_city[0]}",
"body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.",
"score": 72,
"metric": top_city[0],
"href": "/retail",
"tag": "geo",
})
if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0:
opportunities.append({
"id": "opp-gh-contacts",
"title": "Groothandel-contacten ontbreken",
"body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.",
"score": 69,
"metric": "0 GH-contacten",
"href": "/retail",
"tag": "netwerk",
})
if int(k.get("pending_approvals") or 0) > 0:
attention.append({
"id": "att-approvals",
"title": "Goedkeuringen wachten",
"body": f"{int(k['pending_approvals'])} agent-items op needs_approval.",
"score": 55,
"metric": str(int(k["pending_approvals"])),
"href": "/agents",
})
if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100:
attention.append({
"id": "att-bookmarks",
"title": "Geen RSS-bookmarks",
"body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.",
"score": 48,
"metric": "0 bookmarks",
"href": "/marketing",
})
# Pulse score 0-100
score = 35
score += min(18, active_p) # up to +18
score += 12 if stores >= 2000 else (6 if stores >= 500 else 0)
score += 8 if int(k.get("rss_items") or 0) >= 500 else 0
score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0)
score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0
score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0
if cover_pct < 1:
score -= 12
elif cover_pct < 3:
score -= 6
if int(k.get("pending_approvals") or 0) >= 5:
score -= 5
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
score -= 4
score = max(0, min(100, score))
if score >= 75:
label, tone = "Sterk momentum", "good"
elif score >= 55:
label, tone = "Stabiel · kansen open", "mid"
else:
label, tone = "Opbouw-fase · focus nodig", "low"
wins.sort(key=lambda x: -int(x.get("score") or 0))
opportunities.sort(key=lambda x: -int(x.get("score") or 0))
return {
"score": score,
"label": label,
"tone": tone,
"cover_pct": cover_pct,
"white_space": gap,
"wins": wins[:8],
"opportunities": opportunities[:8],
"attention": attention[:6],
"headline": "Wat goed gaat vs waar de kansen liggen",
"sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.",
}
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {}
chain = f.get("chain") or None
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"filters": f,
}
rev = _revenue_kpis()
data["kpis"] = {
"clients_total": _safe_count("clients"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"deals_total": _safe_count("deals"),
"pipeline_eur": float(
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
or 0
),
"supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"),
@@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"),
"active_projects": rev.get("active_projects") or 0,
"margin_month": rev.get("margin_month") or 0,
"margin_year": rev.get("margin_year") or 0,
}
data["goals"] = {
"vision_text": rev.get("vision_text") or "",
"horizon_text": rev.get("horizon_text") or "",
"tagline": rev.get("tagline") or "",
"mid_text": rev.get("mid_text") or "",
}
try:
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_by_agent"] = fetch_all(
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
(days,),
)
except Exception:
data["events_by_agent"] = []
@@ -100,10 +449,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["supermarkets_by_chain"] = []
try:
if store_where:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
if store_where
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12",
tuple(store_params),
)
else:
data["supermarkets_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["supermarkets_by_province"] = []
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_timeline"] = fetch_all(
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY DATE(created_at) ORDER BY day ASC""",
(days,),
)
except Exception:
data["events_timeline"] = []
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["top_opportunities"] = fetch_all(
"""SELECT s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 10"""
ORDER BY ros.halal_opportunity_score DESC LIMIT 12"""
)
except Exception:
data["top_opportunities"] = []
@@ -191,22 +545,20 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
except Exception:
data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
try:
if agent:
agent_where += " AND agent_name = %s"
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""",
(agent,),
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
ORDER BY created_at DESC LIMIT 25""",
(days, agent),
)
except Exception:
data["recent_events"] = []
else:
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25"""
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s)
ORDER BY created_at DESC LIMIT 25""",
(days,),
)
except Exception:
data["recent_events"] = []
@@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
):
if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key])
data["pulse"] = _build_pulse(data)
data["hermes_findings"] = list_hermes_findings(20)
return data
+130
View File
@@ -0,0 +1,130 @@
"""Automatische NAS + second-brain synchronisatie."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Any
import httpx
from app.db import execute, fetch_all, fetch_one
log = logging.getLogger("cockpit.auto_ingest")
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
try:
execute(
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
(source, status, json.dumps(details)),
)
except Exception as exc:
log.warning("ingest log failed: %s", exc)
def _brain_chat_id_for_client(client_id: int) -> int:
return -int(client_id)
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
rows = fetch_all(
"""
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
da.word_count, dl.client_id, c.name AS client_name
FROM document_analytics da
LEFT JOIN document_links dl ON (
da.storage_path = dl.storage_path
OR da.storage_path LIKE dl.storage_path || '/%%'
) AND dl.is_folder = TRUE
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
WHERE da.storage_path IS NOT NULL
ORDER BY da.analyzed_at DESC NULLS LAST
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
synced = 0
errors = 0
async with httpx.AsyncClient(timeout=120.0) as client:
for row in rows:
path = row.get("storage_path") or ""
if not path:
continue
client_id = row.get("client_id")
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
text = (
f"NAS document: {row.get('filename') or path}\n"
f"Pad: {path}\nType: {row.get('doc_type')}\n"
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
)
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
try:
r = await client.post(
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
json={
"chat_id": chat_id,
"direction": "inbound",
"role": "system",
"content_type": "document",
"content_text": text[:4000],
"agent_name": "auto-ingest",
"embed": True,
"chat_type": "client_brain" if client_id else "nas_corpus",
"user_name": row.get("client_name") or "NAS",
"content_json": {"storage_path": path, "client_id": client_id},
"telegram_message_id": msg_hash,
},
)
if r.status_code < 400:
synced += 1
else:
errors += 1
except Exception as exc:
log.warning("brain sync %s: %s", path, exc)
errors += 1
return {"synced": synced, "errors": errors, "candidates": len(rows)}
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
out: dict[str, Any] = {"ok": True, "steps": {}}
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
params={"force": "true" if force_scan else "false"},
)
r.raise_for_status()
out["steps"]["nas_scan"] = r.json()
except Exception as exc:
out["steps"]["nas_scan"] = {"error": str(exc)}
brain = await sync_nas_to_brain()
out["steps"]["brain_sync"] = brain
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
_log_run("auto_sync", status, out)
out["last_logged"] = True
return out
def last_auto_sync() -> dict[str, Any] | None:
row = fetch_one(
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
)
if not row:
return None
d = dict(row)
if hasattr(d.get("created_at"), "isoformat"):
d["created_at"] = d["created_at"].isoformat()
if isinstance(d.get("details"), str):
try:
d["details"] = json.loads(d["details"])
except Exception:
pass
return d
+447 -157
View File
@@ -2,12 +2,15 @@ from __future__ import annotations
import asyncio
import json
import re
from datetime import date, datetime, timezone
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import market_stocks, ollama
from app.services import market_stocks, llm_router
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -166,6 +169,16 @@ def _collect_pipeline_events(data: dict[str, Any]) -> None:
except Exception:
data["recent_events"] = []
try:
data["recent_handoffs"] = fetch_all(
"""SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
FROM agent_handoffs
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["recent_handoffs"] = []
try:
data["pending_items"] = fetch_all(
"""SELECT agent_name, title, event_type, created_at
@@ -230,10 +243,13 @@ def _collect_retail_intel(data: dict[str, Any]) -> None:
try:
data["top_opportunities"] = fetch_all(
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
"""SELECT s.name, s.chain, s.city, s.address, s.postcode, ros.halal_opportunity_score
FROM retail_opportunity_scores ros
JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
WHERE s.name NOT ILIKE '%masala%'
AND s.name NOT ILIKE 'TEST%'
AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%'
ORDER BY ros.halal_opportunity_score DESC LIMIT 8"""
)
except Exception:
data["top_opportunities"] = []
@@ -352,7 +368,57 @@ def _collect_rss_market(data: dict[str, Any]) -> None:
data["rss_items"] = _safe_count("rss_items")
def collect_briefing_data() -> dict[str, Any]:
def _classify_revenue_brand(name: str) -> str:
n = (name or "").lower()
if any(k in n for k in ("foodlinkk", "linknbit", "linknbi", "wereldexport")):
return "foodlinkk"
if "cucina" in n and "foodlinkk" not in n:
return "cucina"
chains = ("ah", "jumbo", "picnic", "plus", "vomar", "dirk", "hoogvliet", "deka", "spar", "doner")
if any(n == c or n.startswith(c + " ") for c in chains):
return "cucina"
if any(k in n for k in ("foodservice", "snack", "diepvries", "halal", "gas stunning", "marketing budget adds cucina")):
return "cucina"
if any(k in n for k in ("fresh supplier", "boerschappen", "subsidies cucina")):
return "foodlinkk"
if any(k in n for k in ("total earnings", "loonkosten", "total expenses", "verkopen cucina")):
return "shared"
return "other"
def _collect_revenue_cockpit(data: dict[str, Any]) -> None:
try:
from app.services import revenue_cockpit as rc
projects = rc.list_projects()
stats = rc.dashboard_stats()
by_brand: dict[str, list] = {"cucina": [], "foodlinkk": [], "shared": [], "other": []}
for p in projects:
if (p.get("status") or "") != "active":
continue
brand = _classify_revenue_brand(p.get("name") or "")
by_brand[brand].append(p)
data["revenue"] = {
"stats": stats,
"by_brand": {
k: sorted(v, key=lambda x: float(x.get("margin_month") or 0), reverse=True)[:6]
for k, v in by_brand.items()
},
}
except Exception:
data["revenue"] = {}
def _collect_export_intel(data: dict[str, Any]) -> None:
try:
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/stats"
r = httpx.get(url, timeout=4.0)
data["export_intel"] = r.json() if r.status_code == 200 else {}
except Exception:
data["export_intel"] = {}
def collect_briefing_data(*, skip_halal: bool = False) -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
@@ -364,10 +430,37 @@ def collect_briefing_data() -> dict[str, Any]:
_collect_nas_analytics(data)
_collect_retail_intel(data)
_collect_rss_market(data)
_collect_revenue_cockpit(data)
_collect_export_intel(data)
if not skip_halal:
_collect_halal_recommendations(data)
data["activity_log"] = _build_activity_log(data)
return data
def _collect_halal_recommendations(data: dict[str, Any]) -> None:
try:
url = f"{settings.TOOLS_API_URL.rstrip('/')}/recommendations/halal/live"
r = httpx.get(url, params={"limit": 5, "brand": "cucina"}, timeout=20.0)
data["halal_recommendations"] = r.json() if r.status_code == 200 else {"items": []}
except Exception:
data["halal_recommendations"] = {"items": []}
# Legacy veld voor backwards compat — eerste 3 engine results
items = (data.get("halal_recommendations") or {}).get("items") or []
data["top_opportunities"] = [
{
"id": i.get("store_id"),
"name": (i.get("title") or "").split(" · ", 1)[-1],
"chain": i.get("chain"),
"city": i.get("city"),
"halal_opportunity_score": i.get("halal_opportunity_score") or i.get("score"),
"composite_score": i.get("score"),
"reasons": i.get("reasons"),
}
for i in items[:5]
]
async def stream_daily_briefing():
"""Yield SSE step events while building the CEO daily report."""
data: dict[str, Any] = {
@@ -377,126 +470,77 @@ async def stream_daily_briefing():
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
yield _briefing_step("crm", "CRM", "crm", "running", "Pipeline & klanten ophalen…")
_collect_crm_core(data)
yield _briefing_step(
"crm", "PostgreSQL", "crm", "ok",
"crm", "CRM", "crm", "ok",
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
"tables: clients, deals, products, suppliers",
)
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
yield _briefing_step("agents", "Herman", "herman", "running", "Open beslissingen filteren…")
_collect_agent_queue(data)
data["pending_approval_requests"] = _fresh_approvals(data.get("pending_approval_requests") or [])
data["pending_approvals"] = len(data["pending_approval_requests"])
pending = data.get("pending_approval_requests") or []
yield _briefing_step(
"agents", "PostgreSQL", "herman", "ok",
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
"table: agent_action_requests",
)
for row in pending[:4]:
agent = row.get("agent_key") or "agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
"agents", "Herman", "herman", "ok",
f"{len(pending)} relevante beslissingen",
)
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
yield _briefing_step("projects", "Operations", "product", "running", "Milestones & projectstatus…")
_collect_projects_ops(data)
yield _briefing_step(
"projects", "PostgreSQL", "sysops", "ok",
f"{len(data.get('project_assets_recent') or [])} project assets · "
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
"tables: project_assets, sysops_activity, config_backups",
"projects", "Operations", "product", "ok",
f"{len(data.get('milestones_pending') or [])} open milestones",
)
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
yield _briefing_step("pipeline", "Finance", "finance", "running", "Pipeline stages laden…")
_collect_pipeline_events(data)
stages = len(data.get("deals_by_stage") or [])
events = data.get("recent_events") or []
yield _briefing_step(
"pipeline", "PostgreSQL", "finance", "ok",
f"{stages} pipeline stages · {len(events)} recente agent-events",
"tables: deals, agent_events",
"pipeline", "Finance", "finance", "ok",
f"{stages} pipeline stages",
)
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
# NAS collected for internal stats but not streamed as CEO noise
_collect_nas_analytics(data)
yield _briefing_step(
"nas", "NAS analytics", "knowledge", "ok",
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
"tables: document_analytics, document_word_counts",
)
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
yield _briefing_step("retail", "Retail", "retail", "running", "Supermarkten & partnerships…")
_collect_retail_intel(data)
opp = data.get("top_opportunities") or []
yield _briefing_step(
"retail", "Retail 360", "retail", "ok",
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} open milestones",
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
)
if opp:
top = opp[0]
yield _briefing_step(
"agent_msg", "agent_mesh", "retail", "agent",
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
"retail", "Retail", "retail", "ok",
f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} milestones",
)
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
yield _briefing_step("rss", "Markt", "marketing", "running", "Food & retail nieuws selecteren…")
_collect_rss_market(data)
trend_n = len(data.get("trending_food") or [])
yield _briefing_step(
"rss", "RSS feeds", "marketing", "ok",
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
"tables: rss_items, rss_feeds, promo_campaigns",
)
yield _briefing_step(
"agent_msg", "agent_mesh", "marketing", "agent",
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
"rss", "Markt", "marketing", "ok",
f"{trend_n} relevante food-headlines",
)
data["activity_log"] = _build_activity_log(data)
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
seen: set[str] = set()
mesh_events = data.get("recent_events") or []
for ev in mesh_events[:10]:
agent = (ev.get("agent_name") or "agent").lower()
if agent in seen:
continue
seen.add(agent)
model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
)
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
yield _briefing_step(
"agent_msg", "agent_mesh", "herman", "agent",
f"Herman → {agent_key}: briefing context gedeeld",
)
yield _briefing_step(
"agent_mesh", "Agent mesh", "herman", "ok",
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
)
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
"Herman schrijft executive samenvatting met AI…",
"ai", f"Herman ({model_label})", "herman", "running",
"Herman schrijft CEO-briefing…",
)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
except (asyncio.TimeoutError, Exception) as exc:
ai_part = ""
yield _briefing_step(
"ai", "Ollama", "herman", "warn",
"AI timeout — gebruik template samenvatting",
"ai", "Herman", "herman", "warn",
"AI timeout — template samenvatting",
str(exc)[:120],
)
else:
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
"ai", f"Herman ({model_label})", "herman", "ok",
f"Briefing klaar ({len(ai_part or '')} tekens)",
)
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
@@ -517,6 +561,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 []:
@@ -565,14 +637,247 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
for row in (data.get("recent_handoffs") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"🔗 {row.get('from_agent')} → {row.get('to_agent')}: {row.get('handoff_type')} ({ts_s})"
)
return lines[:25]
def _strip_activity_prefix(line: str) -> str:
for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
if line.startswith(prefix):
return line[len(prefix) :].strip()
return line.strip()
def _short_activity(line: str, max_len: int = 88) -> str:
s = _strip_activity_prefix(line)
s = re.sub(r" — \d{4}-\d{2}-\d{2}$", "", s)
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", "", s).strip()
s = re.sub(r"\s+", " ", s)
if len(s) > max_len:
return s[: max_len - 1].rstrip() + "…"
return s
def _fmt_eur(val: float | int | None) -> str:
try:
return f"€{float(val or 0):,.0f}"
except (TypeError, ValueError):
return "€—"
def _revenue_line(p: dict[str, Any]) -> str:
mm = p.get("margin_month")
margin = f" · marge/maand {_fmt_eur(mm)}" if mm else ""
obj = p.get("open_objectives") or 0
extra = f" · {obj} open stappen" if obj else ""
return f"{p.get('name')}{margin}{extra}"
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""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 []
# —— Foodlinkk B2B (compact) ——
fl_lines: list[str] = [
f"Pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
]
if export.get("tenders_open") or export.get("distributors"):
fl_lines.append(
f"Export: {export.get('distributors', 0):,} distributeurs · {export.get('tenders_open', 0)} open tenders"
)
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
fl_projects = sorted(
[p for p in fl_projects if p.get("margin_month")],
key=lambda p: -float(p.get("margin_month") or 0),
)
n_rev = 0
for p in fl_projects:
name_l = (p.get("name") or "").lower()
if any(x in name_l for x in ("verkopen", "2030", "total earnings", "total expense", "loonkosten", "boekhouder", "linknbit")):
continue
fl_lines.append(_revenue_line(p))
n_rev += 1
if n_rev >= 2:
break
if export.get("crm_linked"):
fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld")
# —— Cucina B2C (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]:
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]:
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"):
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 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])
summary = f"{data.get('date')} · Foodlinkk B2B + Cucina B2C"
return {
"summary": summary,
"sections": sections,
"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(data.get("activity_log") or []),
"source": "live",
}
def format_live_digest_text(digest: dict[str, Any]) -> str:
"""Platte tekst voor Telegram / Herman chat — halal zit in Cucina-sectie."""
lines = ["📋 Herman briefing · live", "", digest.get("summary") or ""]
for section in digest.get("sections") or []:
icon = section.get("icon") or ""
title = section.get("title") or ""
lines.append("")
lines.append(f"{icon} {title}".strip())
for line in section.get("lines") or []:
lines.append(f" · {line}")
actions = digest.get("actions") or []
if actions:
lines.extend(["", "⚡ Korte termijn"])
for action in actions[:6]:
lines.append(f" · {action}")
long_term = digest.get("long_term") or []
if long_term:
lines.extend(["", "🎯 Lange termijn"])
for item in long_term[:5]:
lines.append(f" · {item}")
updated = digest.get("updated_at")
if updated:
ts = str(updated)[:19].replace("T", " ")
n = digest.get("activity_count")
extra = f" · {n} activiteiten" if n is not None else ""
lines.extend(["", f"Bijgewerkt {ts} UTC{extra}"])
return "\n".join(lines)
def build_live_digest_text(data: dict[str, Any] | None = None) -> str:
payload = data if data is not None else collect_briefing_data()
return format_live_digest_text(build_live_digest(payload))
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
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}",
@@ -622,32 +927,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("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)
@@ -661,73 +945,77 @@ 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 ollama.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:
opp = data.get("top_opportunities") or []
ms = data.get("milestones_pending") or []
lines = [
"## Samenvatting",
f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
]
if opp:
top = opp[0]
lines.append(
f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
)
digest = build_live_digest(data)
lines = ["## Samenvatting", digest["summary"]]
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
"Check Marketing Live Feed voor kant-en-klaar trends",
]
for row in data.get("pending_approval_requests") or []:
if row.get("action_type") == "maintenance_scan":
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
break
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
for a in digest["actions"]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
lines.extend([
"- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
"- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
"- Wekelijks milestones review in Retail 360 sales tab",
])
for a in digest["long_term"]:
lines.append(f"- {a}")
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)",
@@ -765,7 +1053,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 = ""
@@ -775,4 +1063,6 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
return content, serialize_stats(data)
stats = serialize_stats(data)
stats["live_digest"] = build_live_digest(data)
return content, stats
+147
View File
@@ -0,0 +1,147 @@
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
from __future__ import annotations
from typing import Any
from app.db import fetch_all, fetch_one
from app.services import projects as projects_svc
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _serialize_rows(rows: list) -> list[dict]:
return [_serialize_row(r) for r in rows if r]
def get_client_360(client_id: int) -> dict[str, Any]:
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
if not client:
return {"ok": False, "error": "Client not found"}
projs = projects_svc.list_projects(client_id=client_id, limit=50)
links = fetch_all(
"""
SELECT dl.*, p.name AS project_name
FROM document_links dl
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
WHERE dl.client_id = %s
ORDER BY dl.created_at DESC
""",
(client_id,),
)
paths = [l["storage_path"] for l in links if l.get("storage_path")]
docs: list[dict] = []
if paths:
placeholders = ",".join(["%s"] * len(paths))
docs = fetch_all(
f"""
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
sentiment_compound, analyzed_at, user_labels
FROM document_analytics
WHERE storage_path IN ({placeholders})
OR storage_path LIKE ANY (
SELECT dl.storage_path || '/%%' FROM document_links dl
WHERE dl.client_id = %s AND dl.is_folder = TRUE
)
ORDER BY analyzed_at DESC NULLS LAST
LIMIT 80
""",
tuple(paths + [client_id]),
)
sentiment_rows = fetch_all(
"""
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
FROM document_analytics da
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY sentiment_label
""",
(client_id,),
)
top_words = fetch_all(
"""
SELECT dwc.lemma, SUM(dwc.count) AS total
FROM document_word_counts dwc
JOIN document_analytics da ON da.storage_path = dwc.storage_path
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
AND NOT dwc.is_stopword
GROUP BY dwc.lemma
ORDER BY total DESC
LIMIT 25
""",
(client_id,),
)
deals = fetch_all(
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
(client_id,),
)
stores = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
FROM client_supermarket_links l
JOIN supermarkets s ON s.id = l.supermarket_id
WHERE l.client_id = %s
LIMIT 30
""",
(client_id,),
)
trends = fetch_all(
"""
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
COUNT(*) AS docs,
AVG(da.sentiment_compound) AS avg_sentiment,
SUM(da.word_count) AS words
FROM document_analytics da
WHERE da.analyzed_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12
""",
(client_id,),
)
for t in trends:
if hasattr(t.get("month"), "isoformat"):
t["month"] = t["month"].isoformat()
return {
"ok": True,
"client": _serialize_row(client),
"projects": projs,
"links": _serialize_rows(links),
"documents": _serialize_rows(docs),
"sentiment_breakdown": _serialize_rows(sentiment_rows),
"top_words": _serialize_rows(top_words),
"deals": _serialize_rows(deals),
"stores": _serialize_rows(stores),
"monthly_trends": trends,
"stats": {
"linked_paths": len(links),
"documents": len(docs),
"projects": len(projs),
"stores": len(stores),
"deals": len(deals),
},
}
+152
View File
@@ -0,0 +1,152 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: foodlinkk_redis
ports:
- "6379:6379"
restart: unless-stopped
tools-api:
build: ./tools-api
container_name: foodlinkk_tools_api
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
BACKUP_ROOT: /data/backup-root
GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
GITEA_SYSOPS_USER: sysops
GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
GITEA_SYSOPS_NAME: sysops
GITEA_BACKUP_BRANCH: main
PROXMOX_HOST: 10.4.7.14
VM106_IP: 10.4.7.18
OLLAMA_URL: http://10.4.7.19:11434
OLLAMA_MODEL: qwen3:8b
CHROMA_HOST: 10.4.7.19
CHROMA_PORT: "8000"
DOC_INGEST_URL: http://10.4.7.19:8750
BROWSER_USE_URL: http://browser-agent:7790
BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
ports:
- "8700:8700"
volumes:
- /home/aissa/foodlinkk-command-center:/data/backup-root:ro
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
cockpit:
build: ./cockpit
container_name: foodlinkk_cockpit
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
VNC_CDP_URL: http://10.4.7.18:9223
OLLAMA_URL: http://10.4.7.19:11434
OLLAMA_MODEL: qwen3:8b
TOOLS_API_URL: http://tools-api:8700
CHROMA_HOST: 10.4.7.19
CHROMA_PORT: "8000"
DOC_INGEST_URL: http://10.4.7.19:8750
BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
NAS_CLIENTS_ROOT: /data/nas-clients
NAS_SHARE_ROOT: /data/nas-share
ports:
- "8600:8600"
volumes:
- /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro
depends_on:
- tools-api
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
browser-agent:
build: ./browser-agent
container_name: foodlinkk_browser_agent
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
VNC_CDP_URL: http://10.4.7.18:9223
ports:
- "7790:7790"
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
shm_size: "1gb"
gitea:
image: gitea/gitea:1.21-rootless
container_name: foodlinkk_gitea
environment:
GITEA__database__DB_TYPE: sqlite3
GITEA__server__ROOT_URL: http://10.4.7.18:3001/
GITEA__server__HTTP_PORT: 3001
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY: foodlinkk-gitea-secret-2026
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "3001:3001"
volumes:
- gitea_data:/var/lib/gitea
restart: unless-stopped
email-agent:
build: ./email-agent
container_name: foodlinkk_email_agent
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
TOOLS_API_URL: http://tools-api:8700
SYNC_INTERVAL_SEC: 300
ports:
- "8801:8801"
depends_on:
- tools-api
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
prometheus:
image: prom/prometheus:v2.53.0
container_name: foodlinkk_prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
restart: unless-stopped
grafana:
image: grafana/grafana:11.1.0
container_name: foodlinkk_grafana
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: Foodlinkk#2026
GF_SERVER_ROOT_URL: http://10.4.7.18:3002
ports:
- "3002:3000"
depends_on:
- prometheus
restart: unless-stopped
volumes:
gitea_data:
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
"""Parse Succes Sheet revenue tab — runs in cockpit with NAS mount."""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
NAS_SHARE_ROOT = Path(os.getenv("NAS_SHARE_ROOT", "/data/nas-share"))
DEFAULT_FILE = os.getenv("CEO_EXCEL_PATH", "Succes Sheet .xlsx")
DEFAULT_SHEET = os.getenv("CEO_EXCEL_SHEET", "Projects next steps revenue")
_FILL_STYLE = {
"FF92D050": "green",
"FFFF0000": "red",
"FFFFC000": "orange",
"FFFFFF00": "yellow",
"FFBDD7EE": "blue",
"FFDDEBF7": "blue",
"FF0070C0": "blue",
}
def _num(val: Any) -> float | None:
if val is None or val == "":
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _txt(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def _cell_fill_style(cell) -> str:
try:
if not cell or not cell.fill or cell.fill.fill_type != "solid":
return "white"
rgb = cell.fill.fgColor.rgb if cell.fill.fgColor else None
if not rgb or rgb in ("00000000", "FFFFFFFF", "00FFFFFF"):
return "white"
key = rgb[-8:].upper() if len(rgb) >= 8 else rgb.upper()
if key in _FILL_STYLE:
return _FILL_STYLE[key]
short = key[-6:]
for k, v in _FILL_STYLE.items():
if k.endswith(short):
return v
return "white"
except Exception:
return "white"
def parse_revenue_sheet(
rel_path: str = DEFAULT_FILE,
sheet_name: str | None = DEFAULT_SHEET,
nas_root: Path | None = None,
) -> dict[str, Any]:
try:
from openpyxl import load_workbook
except ImportError as exc:
return {"ok": False, "error": f"openpyxl not installed: {exc}"}
root = nas_root or NAS_SHARE_ROOT
full = root / rel_path.lstrip("/")
if not full.is_file():
return {"ok": False, "error": f"File not found: {full}"}
st = full.stat()
wb = load_workbook(full, read_only=False, data_only=True)
names = wb.sheetnames
target_name = sheet_name
if not target_name:
for n in names:
if "project" in n.lower() and "revenue" in n.lower():
target_name = n
break
if not target_name and len(names) > 2:
target_name = names[2]
if not target_name:
return {"ok": False, "error": "Sheet not found", "sheetnames": names}
ws = wb[target_name]
rows = list(ws.iter_rows(values_only=False))
goals: dict[str, str] = {"vision_text": "", "horizon_text": "", "mid_text": "", "tagline": ""}
if rows:
r0 = rows[0]
goals["vision_text"] = _txt(r0[0].value if len(r0) > 0 else "")
goals["horizon_text"] = _txt(r0[1].value if len(r0) > 1 else "")
goals["mid_text"] = _txt(r0[2].value if len(r0) > 2 else "")
goals["tagline"] = _txt(r0[4].value if len(r0) > 4 else (_txt(r0[3].value if len(r0) > 3 else "")))
projects: list[dict[str, Any]] = []
sort_order = 0
for idx, row in enumerate(rows):
if idx <= 1:
continue
cells = list(row) if row else []
name = _txt(cells[1].value if len(cells) > 1 else "")
if not name:
continue
margin_month = _num(cells[2].value if len(cells) > 2 else None)
margin_year = _num(cells[3].value if len(cells) > 3 else None)
next_steps = _txt(cells[4].value if len(cells) > 4 else "")
target_extra = _num(cells[5].value if len(cells) > 5 else None)
row_style = _cell_fill_style(cells[1] if len(cells) > 1 else None)
category = "deal" if margin_month is not None or margin_year is not None else "initiative"
if row_style == "yellow" or re.search(r"foodlinkk|linknbit|subsid|total earnings|loonkosten", name, re.I):
category = "strategic"
projects.append(
{
"name": name,
"category": category,
"margin_month": margin_month,
"margin_year": margin_year,
"target_revenue": target_extra or margin_year,
"next_steps": next_steps,
"status": "active",
"sort_order": sort_order,
"source_row": idx,
"row_style": row_style,
}
)
sort_order += 1
wb.close()
return {
"ok": True,
"source_file": rel_path,
"sheet_name": target_name,
"sheetnames": names,
"file_mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"file_size": st.st_size,
"goals": goals,
"projects": projects,
"parsed_at": datetime.now(timezone.utc).isoformat(),
"project_count": len(projects),
}
+276 -10
View File
@@ -6,6 +6,8 @@ from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
from app.services import packaging_agent
from app.services import webbuilder_agent
from app.services import voice_export_actions
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
@@ -26,6 +28,22 @@ IMAGE_KEYWORDS = (
"make image", "maak plaatje", "/genfoto",
)
BRIEFING_KEYWORDS = (
"/briefing", "briefing", "samenvatting", "dagrapport",
"ochtendbriefing", "avondbriefing", "ceo briefing", "live briefing",
)
def _wants_briefing(raw: str) -> bool:
t = (raw or "").strip().lower()
if not t:
return False
if t.startswith("/briefing"):
return True
if len(t) > 120:
return False
return any(k in t for k in BRIEFING_KEYWORDS)
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
@@ -49,7 +67,14 @@ def _extract_image_prompt(raw: str) -> str:
return t
async def _log_event(agent_name: str, event_type: str, title: str, body: str, metadata: dict | None = None) -> None:
async def _log_event(
agent_name: str,
event_type: str,
title: str,
body: str,
metadata: dict | None = None,
channel: str = "herman",
) -> None:
payload = {
"agent_name": agent_name,
"agent_type": "herman_delegate",
@@ -58,7 +83,7 @@ async def _log_event(agent_name: str, event_type: str, title: str, body: str, me
"body": body,
"metadata": metadata or {},
"status": "completed",
"channel": "herman",
"channel": channel,
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
@@ -68,7 +93,7 @@ async def _log_event(agent_name: str, event_type: str, title: str, body: str, me
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(agent_name, "herman_delegate", event_type, title[:255], body, "completed", "herman", json.dumps(metadata or {})),
(agent_name, "herman_delegate", event_type, title[:255], body, "completed", channel, json.dumps(metadata or {})),
)
except Exception:
pass
@@ -83,7 +108,201 @@ def _pick_agent(raw: str) -> str:
return k
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dict] | None = None) -> list[dict[str, str]]:
steps: list[dict[str, str]] = []
for a in delegated:
key = (a or "").strip().lower()
if key and key != "herman":
steps.append({"agent": key, "status": "delegated", "message": routing_reason or "Aangestuurd door Herman"})
if extra:
steps.extend(extra)
return steps
async def _chat_via_llm_router(
message: str,
channel: str,
*,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
from app.services import hermes_memory, llm_router
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel)
from app.config import settings as _settings
display = "Aïssa" if str(hermes_user) == str(_settings.HERMES_CEO_CHAT_ID) else (username or "gebruiker")
system = hermes_memory.continuity_system(hermes_user, display_name=display)
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
feed_items = await hermes_memory.fetch_recent_feed(hermes_user)
hist = hermes_memory.feed_to_messages(feed_items)
if hist:
messages.append({
"role": "system",
"content": (
f"Recente Telegram-geschiedenis voor chat_id={hermes_user} "
f"({len(hist)} berichten, oud→nieuw). Behandel dit als jouw geheugencontext."
),
})
messages.extend(hist)
messages.append({"role": "user", "content": message})
reply, meta = await llm_router.chat_messages(messages, timeout=180.0, user=hermes_user)
provider = meta.get("provider_label") or "LLM"
model = meta.get("model") or ""
await _log_event(
"herman",
"llm_chat",
f"Herman via {provider}",
message[:2000],
{
"provider": meta,
"channel": channel,
"hermes_user": hermes_user,
"memory_messages": len(hist),
"username": username,
},
channel=channel,
)
return {
"agent": "herman",
"agent_label": f"Herman · {provider}",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": (
f"Cloud LLM · {provider}"
+ (f" · {model}" if model else "")
+ f" · memory:{hermes_user}"
+ (f" · feed:{len(hist)}" if hist else "")
),
"agent_steps": _agent_steps([], f"Via {provider} + Hermes memory"),
"llm_provider": meta,
"hermes_user": hermes_user,
"memory_messages": len(hist),
}
async def chat(
message: str,
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: str | None = None,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
use_voice_router = (
channel in voice_export_actions.VOICE_CHANNELS
or bool(session_id)
or voice_export_actions.wants_export_search(message)
or webbuilder_agent.wants_website(message)
)
if use_voice_router:
routed = await voice_export_actions.handle_voice_command(
message,
session_id=session_id,
confirm_action_id=confirm_action_id,
channel=channel,
)
if routed is not None:
await _log_event(
"herman",
"voice_command" if channel == "voice" else "browser_command",
(routed.get("reply") or "")[:120],
message[:2000],
{
"pending_action": routed.get("pending_action"),
"ui_actions": routed.get("ui_actions"),
"channel": channel,
},
channel=channel,
)
return routed
if channel == "telegram" and _wants_briefing(message):
from app.services.briefing import build_live_digest_text, collect_briefing_data
data = collect_briefing_data()
reply = build_live_digest_text(data)
await _log_event(
"herman",
"briefing",
"Live briefing (Telegram)",
reply[:1500],
{"channel": channel, "source": "live_digest"},
channel=channel,
)
return {
"agent": "herman",
"agent_label": "Herman · Briefing",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": "Live cockpit-samenvatting",
"agent_steps": _agent_steps([], "Live digest uit briefing engine"),
}
if webbuilder_agent.wants_website(message):
try:
outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
project = outcome.get("project") or "website"
preview = outcome.get("preview_url") or ""
reply_lines = [
f"Web Builder is gestart voor project **{project}**.",
f"NAS: {outcome.get('nas_path') or '—'}",
f"Preview (na afloop): {preview}",
"Volg de live terminal: Agents → Terminals → Web Builder.",
]
reply = "\n".join(reply_lines)
await _log_event(
"herman",
"website_delegation",
f"Herman → Web Builder: {project}",
message[:2000],
{
"delegated": ["webbuilder"],
"delegated_agents": ["webbuilder"],
"project": project,
"preview_url": preview,
"channel": channel,
},
channel=channel,
)
delegated = ["webbuilder"]
return {
"agent": "webbuilder",
"agent_label": "Web Builder → Herman",
"reply": reply,
"delegated_agents": delegated,
"routing_reason": "Website-opdracht — Web Builder gestart op Hermes/agy",
"agent_steps": _agent_steps(delegated, "Website build gestart"),
"webbuilder_project": project,
"webbuilder_preview_url": preview,
"ui_actions": [
{
"type": "open_webbuilder_build",
"title": f"Website build — {project}",
"project": project,
"preview_url": preview,
"nas_path": outcome.get("nas_path") or "",
"agents_url": "/agents",
}
],
}
except Exception as exc:
await _log_event(
"webbuilder",
"website_build_error",
"Website start mislukt",
str(exc)[:1500],
{"message": message[:500]},
)
return {
"agent": "webbuilder",
"agent_label": "Web Builder",
"reply": f"Web Builder kon niet starten: {exc}",
"delegated_agents": ["webbuilder"],
}
if packaging_agent.wants_packaging(message):
try:
outcome = await packaging_agent.generate_from_message(message)
@@ -112,7 +331,10 @@ async def chat(message: str) -> dict[str, Any]:
"pdf_url": outcome.get("pdf_url"),
"nas": outcome.get("nas"),
"for_herman": True,
"delegated": ["packaging"],
"channel": channel,
},
channel=channel,
)
await _log_event(
"herman",
@@ -123,14 +345,19 @@ async def chat(message: str) -> dict[str, Any]:
"source_agent": "packaging",
"packaging_id": outcome.get("packaging_id"),
"project_id": outcome.get("cockpit_project_id"),
"delegated": ["packaging"],
"channel": channel,
},
channel=channel,
)
delegated = ["packaging"]
return {
"agent": "packaging",
"agent_label": "Packaging → Herman",
"reply": reply,
"delegated_agents": ["packaging", "herman"],
"delegated_agents": delegated,
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
"agent_steps": _agent_steps(delegated, "Packaging design gegenereerd"),
"packaging_id": outcome.get("packaging_id"),
"packaging_studio_url": outcome.get("studio_url"),
"packaging_pdf_url": outcome.get("pdf_url"),
@@ -165,13 +392,16 @@ async def chat(message: str) -> dict[str, Any]:
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename, "channel": channel}, channel=channel)
delegated = ["design"]
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
"delegated_agents": delegated,
"agent_steps": _agent_steps(delegated, "Afbeelding gegenereerd"),
}
except Exception as exc:
return {
@@ -180,30 +410,66 @@ async def chat(message: str) -> dict[str, Any]:
"reply": f"Kon geen afbeelding genereren: {exc}",
}
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
if backend in ("hermes", "router", "openrouter", "llm"):
try:
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as exc:
hint = (
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
if backend == "hermes"
else "Vul OpenRouter key in via Instellingen → AI / LLM."
)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"LLM provider niet beschikbaar: {exc}. {hint}",
}
# orchestrator | auto — try Herman orchestrator first
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
json={"message": message, "agent": "default", "use_crm": True, "use_rag": True, "channel": channel},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
reason = data.get("routing_reason", "") or ""
await _log_event(
"herman",
"openswarm_delegation",
"voice_delegation" if channel == "voice" else ("telegram_delegation" if channel == "telegram" else "openswarm_delegation"),
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "reason": data.get("routing_reason", "")},
{"delegated": delegated, "delegated_agents": delegated, "reason": reason, "channel": channel},
channel=channel,
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": data.get("routing_reason", ""),
"routing_reason": reason,
"agent_steps": _agent_steps(delegated, reason),
"rag_sources": data.get("rag_sources") or [],
"crm_loaded": data.get("crm_loaded", False),
}
except Exception as exc:
if backend == "auto":
try:
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as llm_exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Orchestrator offline ({exc}). "
f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
"Check Hermes (:8642) of Instellingen → AI / LLM."
),
}
return {
"agent": "herman",
"agent_label": "Herman",
+83
View File
@@ -0,0 +1,83 @@
"""Koppel cockpit Herman-chat aan Hermes/Telegram memory."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import settings
log = logging.getLogger("cockpit.hermes_memory")
_USERNAME_MAP = {
"aissa": "ceo",
"aïssa": "ceo",
"aissa zarioh": "ceo",
"mo": "cto",
"mohamed": "cto",
}
def resolve_hermes_user(
*,
username: str | None = None,
role: str | None = None,
channel: str | None = None,
) -> str:
"""OpenAI-compatible `user` field = Telegram chat_id for Hermes memory."""
uname = (username or "").strip().lower()
r = (role or "").strip().lower()
mapped = _USERNAME_MAP.get(uname)
if mapped == "ceo" or r in ("ceo", "admin") or uname in ("aissa", "aïssa"):
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
if mapped == "cto" or r == "cto" or uname in ("mo", "mohamed"):
return str(settings.HERMES_CTO_CHAT_ID or "789036463")
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
async def fetch_recent_feed(chat_id: str | int, limit: int | None = None) -> list[dict[str, Any]]:
limit = limit or int(getattr(settings, "HERMES_MEMORY_LIMIT", 24) or 24)
base = (settings.HERMES_FEED_URL or "").rstrip("/")
if not base:
return []
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{base}/feed", params={"chat_id": int(chat_id), "limit": limit})
if r.status_code != 200:
return []
items = (r.json() or {}).get("items") or []
return list(reversed(items))
except Exception as exc:
log.warning("hermes feed fetch failed: %s", exc)
return []
def feed_to_messages(items: list[dict[str, Any]], *, max_chars: int = 12000) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
total = 0
for it in items:
text = (it.get("content_text") or "").strip()
if not text:
continue
role_raw = (it.get("role") or "").lower()
role = "assistant" if role_raw in ("assistant", "hermes", "herman") else "user"
if role == "assistant" and (it.get("direction") or "") == "in":
role = "user"
entry = {"role": role, "content": text[:2000]}
total += len(entry["content"])
if total > max_chars:
break
out.append(entry)
return out
def continuity_system(chat_id: str, display_name: str | None = None) -> str:
name = display_name or ("Aïssa" if str(chat_id) == str(settings.HERMES_CEO_CHAT_ID) else "de gebruiker")
return (
"Je bent Herman / Hermes, dezelfde AI Co-CEO als in Telegram. "
f"Deze cockpit-chat is gekoppeld aan Telegram-sessie chat_id={chat_id} ({name}). "
"Gebruik je bestaande Hermes-memory en recente Telegram-geschiedenis. "
"Doe alsof dit dezelfde doorlopende conversatie is — geen nieuw persoon, geen amnesie. "
"Antwoord in het Nederlands, bondig en actiegericht."
)
+485
View File
@@ -0,0 +1,485 @@
"""Unified LLM router — Ollama, DeepSeek, Gemini, Groq, OpenRouter, custom OpenAI-compatible."""
from __future__ import annotations
import json
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import ollama
# Preset catalog for Settings UI (signup links + default models)
LLM_PRESETS: dict[str, dict[str, Any]] = {
"ollama": {
"label": "Ollama (lokaal)",
"api_base_url": "",
"models": ["qwen3:8b", "gemma3:12b", "llama3.2", "mistral"],
"needs_key": False,
"hint": "Geen API key — draait op je Ollama server.",
},
"deepseek": {
"label": "DeepSeek",
"api_base_url": "https://api.deepseek.com/v1",
"models": ["deepseek-chat", "deepseek-reasoner"],
"needs_key": True,
"signup_url": "https://platform.deepseek.com/",
"hint": "Goedkoop · sterk voor code en analyse.",
},
"gemini": {
"label": "Google Gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"models": ["gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"],
"needs_key": True,
"signup_url": "https://aistudio.google.com/apikey",
"hint": "Gratis tier via Google AI Studio.",
},
"groq": {
"label": "Groq (snel · gratis tier)",
"api_base_url": "https://api.groq.com/openai/v1",
"models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
"needs_key": True,
"signup_url": "https://console.groq.com/",
"hint": "Zeer snelle inference · gratis limiet.",
},
"openrouter": {
"label": "OpenRouter",
"api_base_url": "https://openrouter.ai/api/v1",
"models": [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-flash:free",
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
"google/gemini-2.0-flash-exp:free",
"meta-llama/llama-3.3-70b-instruct:free",
],
"default_model": "deepseek/deepseek-v4-flash",
"needs_key": True,
"signup_url": "https://openrouter.ai/keys",
"hint": "Eén API key · DeepSeek V4 Flash via openrouter.ai. Vul key in Instellingen → AI.",
},
"mistral": {
"label": "Mistral AI",
"api_base_url": "https://api.mistral.ai/v1",
"models": ["mistral-small-latest", "open-mistral-nemo"],
"needs_key": True,
"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": "",
"models": [],
"needs_key": True,
"hint": "Elke API die /v1/chat/completions ondersteunt.",
},
}
OPENROUTER_PLACEHOLDER_LABEL = "OpenRouter · DeepSeek V4 Flash"
def ensure_openrouter_placeholder() -> None:
"""Seed inactive OpenRouter row so user can fill API key later in Settings."""
row = fetch_one(
"SELECT id FROM llm_providers WHERE provider_type = 'openrouter' LIMIT 1"
)
if row:
return
execute(
"""INSERT INTO llm_providers (
label, provider_type, api_base_url, api_key, model,
is_active, is_default, extra_config, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())""",
(
OPENROUTER_PLACEHOLDER_LABEL,
"openrouter",
"https://openrouter.ai/api/v1",
"",
"deepseek/deepseek-v4-flash",
False,
False,
json.dumps({"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"}),
),
)
def _env_openrouter_provider() -> dict[str, Any] | None:
from app.config import settings as app_settings
key = (app_settings.OPENROUTER_API_KEY or "").strip()
if not key:
return None
return {
"id": -1,
"label": "OpenRouter (environment)",
"provider_type": "openrouter",
"api_base_url": (app_settings.OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/"),
"api_key": key,
"model": app_settings.OPENROUTER_MODEL or "deepseek/deepseek-v4-flash",
"extra_config": {"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"},
}
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():
row = dict(meta)
row["id"] = key
out.append(row)
return out
def _mask_provider(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
if isinstance(out.get("extra_config"), str):
try:
out["extra_config"] = json.loads(out["extra_config"])
except Exception:
out["extra_config"] = {}
out["api_key_set"] = bool(row.get("api_key"))
out.pop("api_key", None)
preset = LLM_PRESETS.get(out.get("provider_type") or "", {})
out["preset_label"] = preset.get("label", out.get("provider_type"))
out["needs_key"] = preset.get("needs_key", True)
return out
def list_providers() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM llm_providers ORDER BY is_default DESC, is_active DESC, id ASC")
return [_mask_provider(r) for r in rows if r]
def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
if provider_id:
return fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
row = fetch_one(
"SELECT * FROM llm_providers WHERE is_default = TRUE ORDER BY id LIMIT 1"
)
if row:
return row
row = fetch_one(
"SELECT * FROM llm_providers WHERE is_active = TRUE ORDER BY id LIMIT 1"
)
if row:
return row
return fetch_one("SELECT * FROM llm_providers ORDER BY id LIMIT 1")
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
return {
"id": 0,
"label": "Ollama lokaal",
"provider_type": "ollama",
"api_base_url": settings.OLLAMA_URL,
"api_key": "",
"model": settings.OLLAMA_MODEL,
"extra_config": {},
}
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"]
if not (merged.get("model") or "").strip():
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():
if backend in ("router", "openrouter"):
return env_or
return row
async def chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> tuple[str, dict[str, Any]]:
"""Returns (reply_text, meta dict with provider info)."""
prov = resolve_provider(provider_id)
ptype = (prov.get("provider_type") or "ollama").lower()
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
ollama_timeout = min(timeout, 85.0) if ptype == "ollama" else timeout
if ptype == "ollama":
try:
reply = await ollama.chat_messages(messages, timeout=ollama_timeout, model=use_model)
except Exception as exc:
raise RuntimeError(
f"Ollama timeout/ offline ({exc}). "
"Voeg DeepSeek of Gemini toe via Instellingen → AI / LLM voor snelle cloud-chat."
) from exc
return reply, {
"provider_id": prov.get("id"),
"provider_type": "ollama",
"provider_label": prov.get("label") or "Ollama",
"model": use_model,
}
api_key = (prov.get("api_key") or "").strip()
if not api_key:
raise RuntimeError(
f"Geen API key voor {prov.get('label') or ptype} — voeg key toe in Instellingen → AI / LLM"
)
base = (prov.get("api_base_url") or "").strip().rstrip("/")
if not base:
preset = LLM_PRESETS.get(ptype, {})
base = (preset.get("api_base_url") or "").rstrip("/")
if not base:
raise RuntimeError(f"Geen API URL voor provider {prov.get('label')}")
extra = prov.get("extra_config") or {}
if isinstance(extra, str):
try:
extra = json.loads(extra)
except Exception:
extra = {}
url = f"{base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if ptype == "openrouter":
headers["HTTP-Referer"] = extra.get("referer", "https://foodlinkk.local")
headers["X-Title"] = extra.get("title", "Foodlinkk Command Center")
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 2048)),
}
# Hermes: `user` = Telegram chat_id → koppelt API-chat aan bestaande memory/sessie
if user:
payload["user"] = str(user)
elif ptype == "hermes":
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code >= 400:
detail = resp.text[:500]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise RuntimeError(f"{prov.get('label')}: {detail}")
data = resp.json()
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"{prov.get('label')}: leeg antwoord")
content = (choices[0].get("message") or {}).get("content") or ""
return content.strip(), {
"provider_id": prov.get("id"),
"provider_type": ptype,
"provider_label": prov.get("label"),
"model": use_model,
"hermes_user": payload.get("user"),
}
async def stream_chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 90.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
):
"""Yield text deltas from Hermes/OpenAI-compatible streaming chat."""
prov = resolve_provider(provider_id)
ptype = (prov.get("provider_type") or "ollama").lower()
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
if ptype != "hermes":
# non-stream fallback: one chunk
reply, _meta = await chat_messages(
messages,
provider_id=provider_id,
model=model,
timeout=timeout,
user=user,
max_tokens=max_tokens,
temperature=temperature,
)
if reply:
yield reply
return
api_key = (prov.get("api_key") or "").strip()
if not api_key:
raise RuntimeError("Geen Hermes API key")
base = (prov.get("api_base_url") or "").strip().rstrip("/")
if not base:
raise RuntimeError("Geen Hermes API URL")
extra = prov.get("extra_config") or {}
if isinstance(extra, str):
try:
extra = json.loads(extra)
except Exception:
extra = {}
url = f"{base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.35)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 512)),
"stream": True,
}
if user:
payload["user"] = str(user)
else:
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=headers, json=payload) as resp:
if resp.status_code >= 400:
body = (await resp.aread()).decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"Hermes stream fout ({resp.status_code}): {body}")
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith(":"):
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
if data == "[DONE]":
break
continue
try:
obj = json.loads(data)
except Exception:
continue
choices = obj.get("choices") or []
if not choices:
continue
delta = (choices[0].get("delta") or {}).get("content")
if delta:
yield delta
async def generate(
prompt: str,
system: str | None = None,
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
) -> str:
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
reply, _meta = await chat_messages(
messages, provider_id=provider_id, model=model, timeout=timeout
)
return reply
async def test_provider(provider_id: int) -> tuple[bool, str]:
prov = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
if not prov:
return False, "Provider niet gevonden"
try:
reply, meta = await chat_messages(
[{"role": "user", "content": "Antwoord met exact één woord: OK"}],
provider_id=provider_id,
timeout=60.0,
)
msg = f"{meta.get('provider_label')} · {meta.get('model')} — {reply[:80]}"
execute(
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
("ok", msg, provider_id),
)
return True, msg
except Exception as exc:
execute(
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
("error", str(exc)[:500], provider_id),
)
return False, str(exc)
def set_default(provider_id: int) -> None:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
execute(
"UPDATE llm_providers SET is_default = TRUE, is_active = TRUE, updated_at = NOW() WHERE id = %s",
(provider_id,),
)
+517 -50
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from urllib.parse import urlparse
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from app.db import execute, fetch_one, get_connection
from app.db import execute, fetch_all, fetch_one, get_connection
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
@@ -45,6 +46,59 @@ def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
return None
def _extract_parse_info(
html: str,
text: str,
title: str,
final_url: str,
base_url: str,
) -> dict[str, Any]:
soup = BeautifulSoup(html or "", "html.parser")
headings: list[str] = []
for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
if t:
headings.append(t[:140])
links_sample: list[dict[str, str]] = []
seen_hrefs: set[str] = set()
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
continue
if not href.startswith("http"):
href = urljoin(base_url or final_url, href)
if href in seen_hrefs:
continue
seen_hrefs.add(href)
label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
links_sample.append({"href": href, "label": label or href})
if len(links_sample) >= 10:
break
words = len(text.split()) if text else 0
excerpt = ""
if text:
excerpt = text[:320] + ("…" if len(text) > 320 else "")
meta_desc = ""
md = soup.find("meta", attrs={"name": "description"})
if md and md.get("content"):
meta_desc = str(md["content"]).strip()[:240]
return {
"title": title or "(geen titel)",
"final_url": final_url,
"word_count": words,
"char_count": len(text or ""),
"excerpt": excerpt,
"meta_description": meta_desc,
"headings": headings,
"links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
"links_sample": links_sample,
}
def get_page_hash(url: str) -> str | None:
fetched = _fetch_page(url)
if not fetched:
@@ -53,20 +107,31 @@ def get_page_hash(url: str) -> str | None:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
import json
def _save_snapshot(
site_id: int,
url: str,
final_url: str,
title: str,
text: str,
html: str,
parse_info: dict[str, Any] | None = None,
) -> int | None:
parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
metadata = {"source": "monitor", "parse": parse_info}
links_json = json.dumps(parse_info.get("links_sample") or [])
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
site_id=EXCLUDED.site_id, crawled_at=NOW()
site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
links=EXCLUDED.links, crawled_at=NOW()
RETURNING id
""",
(
@@ -76,7 +141,8 @@ def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str
text[:50000],
html[:100000],
site_id,
json.dumps({"source": "monitor"}),
json.dumps(metadata),
links_json,
),
)
page_id = cur.fetchone()[0]
@@ -125,7 +191,8 @@ def add_site(url: str, name: str) -> dict:
)
site_id = cur.fetchone()[0]
if fetched:
_save_snapshot(site_id, url, final_url, title, text, html)
parse_info = _extract_parse_info(html, text, title, final_url, url)
_save_snapshot(site_id, url, final_url, title, text, html, parse_info)
row = fetch_one(
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
(site_id,),
@@ -142,61 +209,461 @@ def remove_site(site_id: int, soft: bool = True) -> None:
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
def trigger_crawl(site_id: int | None = None) -> dict:
try:
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
return {"ok": True, "method": "worker"}
except Exception:
pass
def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
site_id = int(site["id"])
url = site["url"]
name = site.get("name") or url
result: dict[str, Any] = {
"site_id": site_id,
"url": url,
"name": name,
"status": "ERROR",
"changed": False,
"error": None,
}
from app.db import fetch_all
if site_id:
row = fetch_one(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [row] if row else []
else:
sites = fetch_all(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
)
changed = 0
with get_connection() as conn:
with conn.cursor() as cur:
for site in sites:
fetched = _fetch_page(site["url"])
fetched = _fetch_page(url)
if not fetched:
cur.execute(
msg = f"Kan {url} niet bereiken"
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
(site_id, "ERROR", msg),
)
continue
result["error"] = msg
return result
final_url, title, text, html = fetched
parse_info = _extract_parse_info(html, text, title, final_url, url)
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
old_hash = site.get("last_hash")
if old_hash and old_hash != new_hash:
cur.execute(
changed = bool(old_hash and old_hash != new_hash)
if changed:
execute(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site["id"], old_hash, new_hash),
(site_id, old_hash, new_hash),
)
cur.execute(
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
)
changed += 1
result["status"] = "CHANGE"
else:
cur.execute(
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "OK", f"Crawl OK — {title}"),
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
)
cur.execute(
result["status"] = "OK"
execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(new_hash, title, site["id"]),
(new_hash, title, site_id),
)
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
result.update(parse_info)
result["changed"] = changed
result["snapshot_id"] = snapshot_id
return result
def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
if site_id:
row = fetch_one(
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [dict(row)] if row else []
else:
sites = [
dict(r)
for r in fetch_all(
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
)
]
if not sites:
return {
"ok": True,
"method": "inline",
"sites": 0,
"changed": 0,
"errors": 0,
"results": [],
"message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
}
results: list[dict[str, Any]] = []
changed = 0
errors = 0
for site in sites:
row = _crawl_one_site(site)
results.append(row)
if row.get("status") == "ERROR":
errors += 1
if row.get("changed"):
changed += 1
return {
"ok": errors < len(sites),
"method": "inline",
"sites": len(sites),
"changed": changed,
"errors": errors,
"results": results,
}
def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
limit = max(1, min(limit, 50))
if site_id:
rows = fetch_all(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id = %s
ORDER BY cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(site_id, limit),
)
else:
rows = fetch_all(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
ORDER BY cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(limit,),
)
out: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
meta = item.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
parse = (meta or {}).get("parse") or {}
content = item.get("content") or ""
if not parse.get("excerpt") and content:
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
if not parse.get("word_count") and content:
parse["word_count"] = len(str(content).split())
links = item.get("links") or []
if isinstance(links, str):
try:
links = json.loads(links)
except Exception:
links = []
if not parse.get("links_sample") and links:
parse["links_sample"] = links
crawled = item.get("crawled_at")
if crawled is not None and hasattr(crawled, "isoformat"):
item["crawled_at"] = crawled.isoformat()
out.append(
{
"id": item.get("id"),
"site_id": item.get("site_id"),
"site_name": item.get("site_name"),
"url": item.get("url"),
"final_url": item.get("final_url"),
"title": item.get("title") or parse.get("title"),
"crawled_at": item.get("crawled_at"),
"word_count": parse.get("word_count", 0),
"excerpt": parse.get("excerpt", ""),
"meta_description": parse.get("meta_description", ""),
"headings": parse.get("headings") or [],
"links_count": parse.get("links_count", len(links)),
"links_sample": parse.get("links_sample") or links[:10],
}
)
return out
NL_STOPWORDS = frozenset(
"""
de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
for on at by from with about into through during before after above below between under again
further then once here there when where why how all each few more most other some such no nor
not only own same so than too very just don should now naar website home pagina menu contact
service cookie cookies privacy login inloggen registreren meer lees read click klik
""".split()
)
def _tokens(text: str, min_len: int = 4) -> list[str]:
if not text:
return []
raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
item = dict(row)
meta = item.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
parse = (meta or {}).get("parse") or {}
content = str(item.get("content") or "")
if not parse.get("excerpt") and content:
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
if not parse.get("word_count") and content:
parse["word_count"] = len(content.split())
links = item.get("links") or []
if isinstance(links, str):
try:
links = json.loads(links)
except Exception:
links = []
if not parse.get("links_sample") and links:
parse["links_sample"] = links
crawled = item.get("crawled_at")
if crawled is not None and hasattr(crawled, "isoformat"):
crawled = crawled.isoformat()
content_read = content[:content_limit]
if len(content) > content_limit:
content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
return {
"id": item.get("id"),
"site_id": item.get("site_id"),
"site_name": item.get("site_name"),
"url": item.get("url"),
"final_url": item.get("final_url"),
"title": item.get("title") or parse.get("title"),
"crawled_at": crawled,
"word_count": parse.get("word_count", 0),
"char_count": parse.get("char_count", len(content)),
"excerpt": parse.get("excerpt", ""),
"meta_description": parse.get("meta_description", ""),
"headings": parse.get("headings") or [],
"links_count": parse.get("links_count", len(links)),
"links_sample": parse.get("links_sample") or links[:15],
"content_read": content_read,
"content_length": len(content),
"has_full_content": len(content) > 0,
}
def get_parse_page(page_id: int) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.id = %s
""",
(page_id,),
)
if not row:
return None
page = _normalize_page_row(dict(row), content_limit=50000)
page["content_full"] = str(dict(row).get("content") or "")
return page
def build_parse_intelligence(
site_id: int | None = None,
query: str | None = None,
limit: int = 30,
) -> dict[str, Any]:
"""Aggregate parsed pages for analysis — hype terms, trends, readable content."""
limit = max(1, min(limit, 100))
if site_id:
rows = fetch_all(
"""
SELECT DISTINCT ON (cp.site_id)
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id = %s
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
""",
(site_id,),
)
else:
rows = fetch_all(
"""
SELECT DISTINCT ON (cp.site_id)
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id IS NOT NULL
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(limit,),
)
pages = [_normalize_page_row(dict(r)) for r in rows]
q = (query or "").strip().lower()
if q:
pages = [
p
for p in pages
if q in (p.get("title") or "").lower()
or q in (p.get("content_read") or "").lower()
or q in (p.get("excerpt") or "").lower()
or any(q in h.lower() for h in p.get("headings") or [])
]
changed_site_ids: set[int] = set()
recent_changes: list[dict[str, Any]] = []
try:
change_rows = fetch_all(
"""
SELECT pc.site_id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC
LIMIT 30
"""
)
for cr in change_rows:
sid = int(cr["site_id"])
changed_site_ids.add(sid)
ts = cr.get("changed_at")
if ts is not None and hasattr(ts, "isoformat"):
ts = ts.isoformat()
recent_changes.append(
{
"site_id": sid,
"site_name": cr.get("name"),
"url": cr.get("url"),
"changed_at": ts,
}
)
except Exception:
pass
term_scores: dict[str, dict[str, Any]] = {}
heading_counts: dict[str, dict[str, Any]] = {}
def bump_term(term: str, site_name: str, weight: int = 1) -> None:
if len(term) < 3:
return
bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
bucket["score"] += weight
if site_name:
bucket["sites"].add(site_name)
for page in pages:
site_name = page.get("site_name") or str(page.get("site_id") or "")
for tok in _tokens(page.get("title") or "", min_len=3):
bump_term(tok, site_name, 3)
for h in page.get("headings") or []:
hnorm = re.sub(r"\s+", " ", h.strip())[:80]
if len(hnorm) < 3:
continue
hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
hc["count"] += 1
hc["sites"].add(site_name)
for tok in _tokens(h, min_len=3):
bump_term(tok, site_name, 4)
for tok in _tokens(page.get("content_read") or ""):
bump_term(tok, site_name, 1)
for link in page.get("links_sample") or []:
for tok in _tokens(link.get("label") or "", min_len=3):
bump_term(tok, site_name, 2)
hype_terms: list[dict[str, Any]] = []
for term, data in term_scores.items():
if data["score"] < 4:
continue
sites_list = sorted(data["sites"])
hype_terms.append(
{
"term": term,
"score": data["score"],
"site_count": len(sites_list),
"sites": sites_list[:5],
"cross_site": len(sites_list) >= 2,
}
)
hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
hype_terms = hype_terms[:40]
heading_trends = []
for _key, data in heading_counts.items():
if data["count"] < 1:
continue
heading_trends.append(
{
"label": data["label"],
"count": data["count"],
"sites": sorted(data["sites"])[:6],
"cross_site": len(data["sites"]) >= 2,
}
)
heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
heading_trends = heading_trends[:25]
top_term_set = {t["term"] for t in hype_terms[:15]}
food_signals = frozenset(
"halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
"groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
"supermarkt retail assortiment prijs private label merk".split()
)
for page in pages:
signals: list[str] = []
wc = int(page.get("word_count") or 0)
sid = page.get("site_id")
if wc >= 1500:
signals.append("Rijke pagina — veel te analyseren")
elif wc >= 400:
signals.append("Normale pagina-dichtheid")
if sid in changed_site_ids:
signals.append("Recent gewijzigd — mogelijke hype/shift")
page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
matched_hype = [t for t in top_term_set if t in page_terms]
food_hits = [t for t in page_terms if t in food_signals]
for t in matched_hype[:4]:
signals.append(f"Trend-term: {t}")
for t in food_hits[:3]:
if f"Trend-term: {t}" not in signals:
signals.append(f"Food-signaal: {t}")
if page.get("meta_description"):
signals.append("SEO meta beschikbaar")
page["signals"] = signals[:8]
page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
page["recently_changed"] = sid in changed_site_ids
pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
total_words = sum(int(p.get("word_count") or 0) for p in pages)
return {
"summary": {
"pages": len(pages),
"total_words": total_words,
"themes_detected": len(hype_terms),
"headings_unique": len(heading_trends),
"changes_7d": len(recent_changes),
"query": q or None,
},
"hype_terms": hype_terms,
"heading_trends": heading_trends,
"recent_changes": recent_changes[:12],
"pages": pages,
}
+2 -2
View File
@@ -13,10 +13,10 @@ async def generate(prompt: str, system: str | None = None, timeout: float = 300.
return await chat_messages(messages, timeout=timeout)
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0, model: str | None = None) -> str:
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
payload = {
"model": settings.OLLAMA_MODEL,
"model": model or settings.OLLAMA_MODEL,
"messages": messages,
"think": False,
"stream": False,
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.4
python-multipart==0.0.12
psycopg2-binary==2.9.9
httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
openpyxl>=3.1.0
tweepy==4.15.0
+424
View File
@@ -0,0 +1,424 @@
"""Revenue Cockpit — DB operations, import, tracking, agent tasks."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services import agent_integration
from app.services.excel_import import DEFAULT_FILE, DEFAULT_SHEET, parse_revenue_sheet
def fetch_excel_parse(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
result = parse_revenue_sheet(path, sheet)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "Excel parse failed")
return result
async def fetch_excel_parse_async(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
return fetch_excel_parse(path, sheet)
def _ser(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _log_change(entity_type: str, entity_id: int, field: str, old: Any, new: Any, by: str = "ceo") -> None:
execute(
"""
INSERT INTO revenue_change_log (entity_type, entity_id, field_name, old_value, new_value, changed_by)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(entity_type, entity_id, field, str(old) if old is not None else None, str(new) if new is not None else None, by),
)
def import_from_parsed(parsed: dict[str, Any], imported_by: str = "ceo", replace: bool = True) -> dict[str, Any]:
if replace:
execute("DELETE FROM revenue_objectives WHERE project_id IN (SELECT id FROM revenue_projects)")
execute("DELETE FROM revenue_projects")
execute("UPDATE revenue_cockpit_goals SET is_active = FALSE WHERE is_active = TRUE")
goals = parsed.get("goals") or {}
g = fetch_one(
"""
INSERT INTO revenue_cockpit_goals (vision_text, horizon_text, mid_text, tagline, is_active)
VALUES (%s, %s, %s, %s, TRUE)
RETURNING *
""",
(
goals.get("vision_text", ""),
goals.get("horizon_text", ""),
goals.get("mid_text", ""),
goals.get("tagline", ""),
),
)
count = 0
for p in parsed.get("projects") or []:
row = fetch_one(
"""
INSERT INTO revenue_projects
(name, category, margin_month, margin_year, target_revenue, next_steps, status, sort_order, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
p["name"],
p.get("category", "deal"),
p.get("margin_month"),
p.get("margin_year"),
p.get("target_revenue"),
p.get("next_steps"),
p.get("status", "active"),
p.get("sort_order", count),
json.dumps({
"source_row": p.get("source_row"),
"row_style": p.get("row_style", "white"),
"imported": True,
}),
),
)
pid = row["id"] if row else None
if pid and p.get("next_steps"):
for i, line in enumerate([ln.strip() for ln in p["next_steps"].split("\n") if ln.strip()][:5]):
execute(
"""
INSERT INTO revenue_objectives (project_id, title, description, status, sort_order)
VALUES (%s, %s, %s, 'open', %s)
""",
(pid, line[:255], p["next_steps"] if i == 0 else None, i),
)
count += 1
run = fetch_one(
"""
INSERT INTO revenue_import_runs (source_file, sheet_name, rows_imported, goals_imported, imported_by, metadata)
VALUES (%s, %s, %s, TRUE, %s, %s::jsonb)
RETURNING *
""",
(
parsed.get("source_file", ""),
parsed.get("sheet_name", ""),
count,
imported_by,
json.dumps({"parsed_at": parsed.get("parsed_at"), "file_mtime": parsed.get("file_mtime")}),
),
)
take_snapshot()
return {"ok": True, "projects_imported": count, "goals": _ser(g), "import_run": _ser(run)}
def get_active_goals() -> dict[str, Any] | None:
return _ser(fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1"))
def update_goals(data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1")
if not old:
return None
fields = ("vision_text", "horizon_text", "mid_text", "tagline")
sets, params = [], []
for k in fields:
if k in data:
sets.append(f"{k} = %s")
params.append(data[k])
if str(old.get(k)) != str(data[k]):
_log_change("goals", old["id"], k, old.get(k), data[k], changed_by)
if not sets:
return get_active_goals()
params.append(old["id"])
execute(
f"UPDATE revenue_cockpit_goals SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s",
tuple(params),
)
return get_active_goals()
def list_projects(status: str | None = None) -> list[dict[str, Any]]:
clauses, params = [], []
if status:
clauses.append("status = %s")
params.append(status)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""
SELECT p.*,
(SELECT COUNT(*) FROM revenue_objectives o WHERE o.project_id = p.id AND o.status = 'open') AS open_objectives,
(SELECT COUNT(*) FROM agent_tasks t WHERE t.revenue_project_id = p.id AND t.status NOT IN ('completed','cancelled')) AS open_tasks
FROM revenue_projects p
{where}
ORDER BY p.sort_order ASC, p.name ASC
""",
tuple(params) if params else None,
)
return [_ser({**r, "row_style": _infer_row_style(r.get("name"), r.get("margin_month"), r.get("metadata"))}) for r in rows]
def get_project(project_id: int) -> dict[str, Any] | None:
p = _ser(fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,)))
if not p:
return None
p["objectives"] = [_ser(r) for r in fetch_all(
"SELECT * FROM revenue_objectives WHERE project_id = %s ORDER BY sort_order, id",
(project_id,),
)]
p["tasks"] = [_ser(r) for r in fetch_all(
"""
SELECT * FROM agent_tasks WHERE revenue_project_id = %s
ORDER BY created_at DESC LIMIT 50
""",
(project_id,),
)]
p["history"] = [_ser(r) for r in fetch_all(
"""
SELECT * FROM revenue_change_log
WHERE (entity_type = 'project' AND entity_id = %s)
OR (entity_type = 'objective' AND entity_id IN (
SELECT id FROM revenue_objectives WHERE project_id = %s))
ORDER BY created_at DESC LIMIT 30
""",
(project_id, project_id),
)]
p["row_style"] = _infer_row_style(p.get("name"), p.get("margin_month"), p.get("metadata"))
return p
def set_project_row_style(project_id: int, row_style: str) -> None:
old = fetch_one("SELECT metadata FROM revenue_projects WHERE id = %s", (project_id,))
meta = dict(old.get("metadata") or {}) if old else {}
meta["row_style"] = row_style
execute(
"UPDATE revenue_projects SET metadata = %s::jsonb, updated_at = NOW() WHERE id = %s",
(json.dumps(meta), project_id),
)
def update_project(project_id: int, data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,))
if not old:
return None
fields = {
"name": data.get("name"),
"category": data.get("category"),
"margin_month": data.get("margin_month"),
"margin_year": data.get("margin_year"),
"target_revenue": data.get("target_revenue"),
"next_steps": data.get("next_steps"),
"status": data.get("status"),
"priority": data.get("priority"),
}
sets, params = [], []
for k, v in fields.items():
if k in data:
sets.append(f"{k} = %s")
params.append(v)
if str(old.get(k)) != str(v):
_log_change("project", project_id, k, old.get(k), v, changed_by)
if not sets:
return get_project(project_id)
params.append(project_id)
execute(f"UPDATE revenue_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
return get_project(project_id)
def _infer_row_style(name: str, margin_month: Any, metadata: Any) -> str:
if isinstance(metadata, dict) and metadata.get("row_style"):
return str(metadata["row_style"])
n = (name or "").lower()
if "foodlinkk food marketing" in n or "total earnings" in n or "loonkosten aissa" in n:
return "yellow"
if "foodservice" in n or n.strip() == "dirk":
return "orange"
if margin_month is not None and margin_month > 0:
return "green"
if any(x in n for x in ("plus", "vomar", "hoogvliet", "deka", "spar", "doner palace", "zakat")):
return "red"
return "white"
def create_objective(project_id: int, title: str, description: str | None = None, priority: str = "normal") -> dict[str, Any]:
row = fetch_one(
"""
INSERT INTO revenue_objectives (project_id, title, description, priority)
VALUES (%s, %s, %s, %s)
RETURNING *
""",
(project_id, title.strip(), description, priority),
)
_log_change("objective", row["id"], "created", None, title, "ceo")
return _ser(row) or {}
def update_objective(objective_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,))
if not old:
return None
for k in ("title", "description", "status", "priority", "due_date"):
if k in data:
execute(
f"UPDATE revenue_objectives SET {k} = %s, updated_at = NOW() WHERE id = %s",
(data[k], objective_id),
)
_log_change("objective", objective_id, k, old.get(k), data[k])
return _ser(fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,)))
def dashboard_stats() -> dict[str, Any]:
goals = get_active_goals()
totals = fetch_one(
"""
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active_projects,
COALESCE(SUM(margin_year) FILTER (WHERE status = 'active'), 0) AS total_margin_year,
COALESCE(SUM(margin_month) FILTER (WHERE status = 'active'), 0) AS total_margin_month,
COUNT(*) FILTER (WHERE category = 'deal' AND status = 'active') AS active_deals
FROM revenue_projects
"""
) or {}
open_obj = fetch_one("SELECT COUNT(*) AS n FROM revenue_objectives WHERE status = 'open'") or {"n": 0}
open_tasks = fetch_one(
"SELECT COUNT(*) AS n FROM agent_tasks WHERE status NOT IN ('completed','cancelled') AND revenue_project_id IS NOT NULL"
) or {"n": 0}
prev = fetch_one(
"SELECT * FROM revenue_snapshots WHERE snapshot_date < CURRENT_DATE ORDER BY snapshot_date DESC LIMIT 1"
)
return {
"goals": goals,
"active_projects": int(totals.get("active_projects") or 0),
"active_deals": int(totals.get("active_deals") or 0),
"total_margin_year": float(totals.get("total_margin_year") or 0),
"total_margin_month": float(totals.get("total_margin_month") or 0),
"open_objectives": int(open_obj.get("n") or 0),
"open_agent_tasks": int(open_tasks.get("n") or 0),
"previous_snapshot": _ser(prev),
}
def take_snapshot() -> dict[str, Any]:
stats = dashboard_stats()
row = fetch_one(
"""
INSERT INTO revenue_snapshots
(snapshot_date, total_margin_year, total_margin_month, active_projects, open_objectives, open_agent_tasks, payload)
VALUES (CURRENT_DATE, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (snapshot_date) DO UPDATE SET
total_margin_year = EXCLUDED.total_margin_year,
total_margin_month = EXCLUDED.total_margin_month,
active_projects = EXCLUDED.active_projects,
open_objectives = EXCLUDED.open_objectives,
open_agent_tasks = EXCLUDED.open_agent_tasks,
payload = EXCLUDED.payload,
created_at = NOW()
RETURNING *
""",
(
stats["total_margin_year"],
stats["total_margin_month"],
stats["active_projects"],
stats["open_objectives"],
stats["open_agent_tasks"],
json.dumps({"goals_id": (stats.get("goals") or {}).get("id")}),
),
)
return _ser(row) or {}
def list_snapshots(limit: int = 90) -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM revenue_snapshots ORDER BY snapshot_date DESC LIMIT %s",
(max(1, min(limit, 365)),),
)
return [_ser(r) for r in rows]
def assign_agent_task(
project_id: int,
agent_name: str,
title: str,
description: str | None = None,
objective_id: int | None = None,
priority: str = "normal",
delegate_herman: bool = True,
) -> dict[str, Any]:
project = fetch_one("SELECT name FROM revenue_projects WHERE id = %s", (project_id,))
if not project:
raise ValueError("Project not found")
agent = agent_name.strip().lower()
desc = description or ""
row = fetch_one(
"""
INSERT INTO agent_tasks
(agent_name, title, description, status, priority, assigned_by, revenue_project_id, revenue_objective_id, source)
VALUES (%s, %s, %s, 'pending', %s, 'ceo', %s, %s, 'revenue_cockpit')
RETURNING *
""",
(agent, title.strip(), desc, priority, project_id, objective_id),
)
task = _ser(row) or {}
body = f"Revenue Cockpit · {project['name']}: {title}"
if desc:
body += f"\n{desc}"
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata, related_table, related_id)
VALUES (%s, 'revenue_cockpit', 'task_assigned', %s, %s, 'pending', 'revenue_cockpit', %s::jsonb, 'agent_tasks', %s)
""",
(
agent,
title.strip(),
body,
json.dumps({"project_id": project_id, "objective_id": objective_id, "task_id": task.get("id")}),
task.get("id"),
),
)
except Exception:
pass
if delegate_herman and agent != "herman":
try:
agent_integration.create_handoff(
"ceo",
agent,
handoff_type="task",
payload={"task_id": task.get("id"), "project_id": project_id, "title": title},
status="pending",
)
except Exception:
pass
_log_change("project", project_id, "agent_task", None, title, "ceo")
return task
def list_agent_tasks(limit: int = 50) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT t.*, p.name AS project_name
FROM agent_tasks t
LEFT JOIN revenue_projects p ON p.id = t.revenue_project_id
WHERE t.revenue_project_id IS NOT NULL
ORDER BY t.created_at DESC
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
return [_ser(r) for r in rows]
async def delegate_via_herman(project_id: int, message: str) -> dict[str, Any]:
from app.services import herman as herman_service
project = get_project(project_id)
if not project:
raise ValueError("Project not found")
prompt = f"[Revenue Cockpit · {project['name']}] {message}"
result = await herman_service.chat(prompt)
return {"ok": True, "result": result, "project_id": project_id}
@@ -0,0 +1,594 @@
"""Voice/browser/Telegram command router: export intel, webbuilder/agy, bevestiging + UI."""
from __future__ import annotations
import re
import time
import uuid
from typing import Any
import httpx
from app.config import settings
from app.services import webbuilder_agent
SESSION_TTL_SEC = 3600
_pending: dict[str, dict[str, Any]] = {}
VOICE_CHANNELS = frozenset({"voice", "browser", "telegram"})
LOCATION_ALIASES: dict[str, dict[str, str]] = {
"dubai": {"country": "AE", "q": "Dubai", "label": "Dubai (VAE)"},
"abu dhabi": {"country": "AE", "q": "Abu Dhabi", "label": "Abu Dhabi (VAE)"},
"sharjah": {"country": "AE", "q": "Sharjah", "label": "Sharjah (VAE)"},
"vae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
"uae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
"emiraten": {"country": "AE", "q": "", "label": "VAE"},
"saudi": {"country": "SA", "q": "", "label": "Saoedi-Arabië"},
"riyadh": {"country": "SA", "q": "Riyadh", "label": "Riyadh (SA)"},
"jeddah": {"country": "SA", "q": "Jeddah", "label": "Jeddah (SA)"},
"qatar": {"country": "QA", "q": "", "label": "Qatar"},
"doha": {"country": "QA", "q": "Doha", "label": "Doha (Qatar)"},
"kuwait": {"country": "KW", "q": "", "label": "Koeweit"},
"nederland": {"country": "NL", "q": "", "label": "Nederland"},
"amsterdam": {"country": "NL", "q": "Amsterdam", "label": "Amsterdam"},
"rotterdam": {"country": "NL", "q": "Rotterdam", "label": "Rotterdam"},
"belgië": {"country": "BE", "q": "", "label": "België"},
"belgie": {"country": "BE", "q": "", "label": "België"},
"antwerpen": {"country": "BE", "q": "Antwerp", "label": "Antwerpen"},
"duitsland": {"country": "DE", "q": "", "label": "Duitsland"},
"berlijn": {"country": "DE", "q": "Berlin", "label": "Berlijn"},
"frankfurt": {"country": "DE", "q": "Frankfurt", "label": "Frankfurt"},
"turkije": {"country": "TR", "q": "", "label": "Turkije"},
"istanbul": {"country": "TR", "q": "Istanbul", "label": "Istanbul"},
"marokko": {"country": "MA", "q": "", "label": "Marokko"},
"casablanca": {"country": "MA", "q": "Casablanca", "label": "Casablanca"},
}
ENTITY_ALIASES: dict[str, str] = {
"distri": "distributor,wholesaler,importer,logistics",
"distributeur": "distributor,wholesaler,importer,logistics",
"distributeurs": "distributor,wholesaler,importer,logistics",
"groothandel": "wholesaler,distributor",
"groothandels": "wholesaler,distributor",
"importeur": "importer",
"importeurs": "importer",
"logistiek": "logistics",
"restaurant": "restaurant",
"restaurants": "restaurant",
"cateraar": "caterer",
"cateraars": "caterer",
"slager": "butcher",
"slagers": "butcher",
"döner": "doner",
"doner": "doner",
}
SEARCH_TRIGGERS = (
"zoek", "opzoeken", "vind", "zoeken", "toon", "laat zien", "lijst",
"geef me", "haal op", "export intel", "wereldexport",
)
YES_RE = re.compile(
r"^(ja|jawel|jep|yep|ok|oke|oké|okay|klopt|bevestig|doe maar|graag|precies|goed|akkoord|start|uitvoeren|doorgaan)\b",
re.I,
)
NO_RE = re.compile(r"^(nee|neen|stop|annuleer|niet|cancel|laat maar|wacht)\b", re.I)
def _cleanup_sessions() -> None:
now = time.time()
dead = [k for k, v in _pending.items() if now - float(v.get("_ts", 0)) > SESSION_TTL_SEC]
for k in dead:
_pending.pop(k, None)
def is_confirmation_yes(text: str) -> bool:
t = (text or "").strip()
return bool(t and YES_RE.search(t))
def is_confirmation_no(text: str) -> bool:
t = (text or "").strip()
return bool(t and NO_RE.search(t))
def wants_export_search(text: str) -> bool:
t = (text or "").lower()
if not any(k in t for k in SEARCH_TRIGGERS):
return False
has_loc = any(alias in t for alias in LOCATION_ALIASES)
has_ent = any(alias in t for alias in ENTITY_ALIASES)
return has_loc or has_ent
def _detect_location(text: str) -> dict[str, str] | None:
t = text.lower()
best: tuple[int, dict[str, str]] | None = None
for alias, loc in LOCATION_ALIASES.items():
if alias in t:
score = len(alias)
if best is None or score > best[0]:
best = (score, loc)
return best[1] if best else None
def _detect_entity_types(text: str) -> str:
t = text.lower()
found: list[str] = []
for alias, types in ENTITY_ALIASES.items():
if alias in t:
for et in types.split(","):
if et not in found:
found.append(et)
if not found and any(w in t for w in ("distri", "distributeur", "groothandel", "b2b", "leverancier")):
return "distributor,wholesaler,importer,logistics"
return ",".join(found) if found else "distributor,wholesaler,importer,logistics"
def _entity_label(entity_types: str) -> str:
labels = {
"distributor": "distributeurs",
"wholesaler": "groothandels",
"importer": "importeurs",
"logistics": "logistiek",
"restaurant": "restaurants",
"caterer": "cateraars",
"butcher": "slagers",
"doner": "dönerzaken",
}
parts = [labels.get(x.strip(), x.strip()) for x in entity_types.split(",") if x.strip()]
return ", ".join(parts) if parts else "bedrijven"
def parse_export_search(text: str) -> dict[str, Any]:
loc = _detect_location(text)
entity_types = _detect_entity_types(text)
missing: list[str] = []
if not loc:
missing.append("locatie")
params: dict[str, Any] = {
"country": (loc or {}).get("country", ""),
"q": (loc or {}).get("q", ""),
"entity_types": entity_types,
"limit": 50,
}
label_loc = (loc or {}).get("label", "")
return {
"params": params,
"location_label": label_loc,
"entity_label": _entity_label(entity_types),
"missing": missing,
"incomplete": bool(missing),
}
def store_pending(session_id: str, action: dict[str, Any]) -> str:
_cleanup_sessions()
action_id = str(uuid.uuid4())[:12]
action = dict(action)
action["id"] = action_id
action["_ts"] = time.time()
_pending[session_id] = action
return action_id
def get_pending(session_id: str) -> dict[str, Any] | None:
_cleanup_sessions()
row = _pending.get(session_id)
if not row:
return None
if time.time() - float(row.get("_ts", 0)) > SESSION_TTL_SEC:
_pending.pop(session_id, None)
return None
return row
def clear_pending(session_id: str) -> None:
_pending.pop(session_id, None)
def build_confirmation_question(parsed: dict[str, Any]) -> str:
loc = parsed.get("location_label") or "de geselecteerde regio"
ent = parsed.get("entity_label") or "bedrijven"
return (
f"Ik ga **{ent}** in **{loc}** voor je opzoeken in Export Intel.\n\n"
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren, "
"of geef aan wat ik moet aanpassen (bijv. alleen distributeurs, of een andere stad)."
)
def build_clarification_question(parsed: dict[str, Any]) -> str:
missing = parsed.get("missing") or []
if "locatie" in missing:
return (
"In welke **stad of welk land** wil je zoeken? "
"Bijvoorbeeld: Dubai, VAE, Nederland, Frankfurt…"
)
return "Kun je iets specifieker zijn over wat je zoekt en waar?"
async def fetch_export_entities(params: dict[str, Any]) -> dict[str, Any]:
query = {k: v for k, v in params.items() if v not in (None, "")}
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/entities"
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(url, params=query)
resp.raise_for_status()
return resp.json()
def _open_url(params: dict[str, Any]) -> str:
qs = []
if params.get("country"):
qs.append(f"country={params['country']}")
if params.get("q"):
qs.append(f"q={params['q']}")
qs.append("tab=distributors")
return "/export-intel?" + "&".join(qs)
async def execute_search_action(action: dict[str, Any]) -> dict[str, Any]:
params = action.get("params") or {}
data = await fetch_export_entities(params)
items = data.get("items") or []
total = int(data.get("total") or len(items))
loc = action.get("location_label") or params.get("q") or params.get("country") or "markt"
ent = action.get("entity_label") or "bedrijven"
title = f"{ent.title()} — {loc}"
if not items:
reply = (
f"Ik heb gezocht maar vond **geen** {ent} in {loc}. "
"Wil je dat ik een sync start of een bredere regio probeer?"
)
else:
reply = (
f"Gevonden: **{total}** {ent} in {loc}. "
f"Ik toon de eerste {min(len(items), 50)} in het resultatenvenster."
)
return {
"agent": "sourcing",
"agent_label": "Export Intel → Herman",
"reply": reply,
"delegated_agents": ["sourcing", "export_intel"],
"routing_reason": f"Export Intel zoekopdracht: {title}",
"agent_steps": [
{"agent": "export_intel", "status": "done", "message": f"{total} resultaten"},
{"agent": "sourcing", "status": "delegated", "message": "Marktdata opgehaald"},
],
"needs_confirmation": False,
"ui_actions": [
{
"type": "show_export_results",
"title": title,
"entities": items[:50],
"total": total,
"params": params,
"open_url": _open_url(params),
}
],
}
def _pending_summary(action: dict[str, Any]) -> dict[str, Any]:
atype = action.get("type", "")
base = {"id": action.get("id"), "type": atype}
if atype == "export_intel_search":
base["location_label"] = action.get("location_label")
base["entity_label"] = action.get("entity_label")
base["params"] = action.get("params")
elif atype == "webbuilder_build":
base["project"] = action.get("project")
base["entity_label"] = f"website {action.get('project', '')}"
base["location_label"] = "Agy · Antigravity"
return base
def build_webbuilder_confirmation(project: str, raw: str) -> str:
snippet = (raw or "")[:240]
return (
f"Ik stuur **Agy (Antigravity)** op Hermes aan om een website te bouwen.\n\n"
f"**Project:** {project}\n"
f"**Opdracht:** {snippet}{'…' if len(raw or '') > 240 else ''}\n\n"
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren."
)
async def execute_webbuilder_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
raw = action.get("raw_message") or ""
project = action.get("project") or webbuilder_agent.extract_project_name(raw)
try:
outcome = await webbuilder_agent.generate_from_message(raw, channel=channel, wait=False)
except Exception as exc:
return {
"agent": "webbuilder",
"agent_label": "Web Builder",
"reply": f"Agy kon niet starten op Hermes: {exc}\n\nControleer VM107 webbuilder-api (:8798).",
"delegated_agents": ["webbuilder"],
"needs_confirmation": False,
}
project = outcome.get("project") or project
preview = outcome.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
nas_path = outcome.get("nas_path") or ""
reply = (
f"Agy is gestart voor **{project}**.\n"
f"Preview (na build): {preview}\n"
f"NAS: {nas_path}\n"
"Volg live: Agents → Terminals → Web Builder."
)
return {
"agent": "webbuilder",
"agent_label": "Agy / Web Builder → Herman",
"reply": reply,
"delegated_agents": ["webbuilder"],
"routing_reason": "Website via Antigravity CLI (agy) op Hermes VM107",
"agent_steps": [
{"agent": "webbuilder", "status": "running", "message": f"agy bouwt {project}"},
{"agent": "herman", "status": "delegated", "message": "Voice → build gestart"},
],
"webbuilder_project": project,
"webbuilder_preview_url": preview,
"needs_confirmation": False,
"ui_actions": [
{
"type": "open_webbuilder_build",
"title": f"Website build — {project}",
"project": project,
"preview_url": preview,
"nas_path": nas_path,
"agents_url": "/agents",
"status_hint": "Build duurt enkele minuten — preview opent na afloop",
}
],
}
async def execute_pending_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
atype = action.get("type")
if atype == "webbuilder_build":
return await execute_webbuilder_action(action, channel=channel)
return await execute_search_action(action)
def _confirmation_reminder(pending: dict[str, Any]) -> str:
if pending.get("type") == "webbuilder_build":
return build_webbuilder_confirmation(pending.get("project", "website"), pending.get("raw_message", ""))
return build_confirmation_question(pending)
async def handle_voice_command(
message: str,
session_id: str | None = None,
confirm_action_id: str | None = None,
channel: str = "voice",
) -> dict[str, Any] | None:
"""Unified voice/browser router: bevestiging + export + webbuilder/agy."""
sid = (session_id or "").strip() or "default"
text = (message or "").strip()
if not text:
return None
pending = get_pending(sid)
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
clear_pending(sid)
return await execute_pending_action(pending, channel=channel)
if pending:
if is_confirmation_yes(text):
clear_pending(sid)
return await execute_pending_action(pending, channel=channel)
if is_confirmation_no(text):
clear_pending(sid)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
"needs_confirmation": False,
}
if pending.get("awaiting") == "location" and pending.get("type") == "export_intel_search":
loc = _detect_location(text)
if loc:
merged = dict(pending)
merged["params"] = dict(pending.get("params") or {})
merged["params"]["country"] = loc["country"]
merged["params"]["q"] = loc.get("q", "")
merged["location_label"] = loc["label"]
merged.pop("awaiting", None)
merged.pop("incomplete", None)
merged.pop("missing", None)
action_id = store_pending(sid, merged)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(merged),
"needs_confirmation": True,
"pending_action": _pending_summary(merged),
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question({"missing": ["locatie"]}),
"needs_confirmation": True,
"clarification": True,
}
if webbuilder_agent.wants_website(text):
project = webbuilder_agent.extract_project_name(text)
action_id = store_pending(sid, {
"type": "webbuilder_build",
"project": project,
"raw_message": text,
})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_webbuilder_confirmation(project, text),
"needs_confirmation": True,
"pending_action": _pending_summary(get_pending(sid) or {}),
}
if wants_export_search(text):
parsed = parse_export_search(text)
if not parsed.get("incomplete"):
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": _pending_summary(get_pending(sid) or {}),
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Ik wacht nog op bevestiging.\n\n{_confirmation_reminder(pending)}\n\n"
"Zeg **ja** of **nee**."
),
"needs_confirmation": True,
"pending_action": _pending_summary(pending),
}
if webbuilder_agent.wants_website(text):
project = webbuilder_agent.extract_project_name(text)
store_pending(sid, {"type": "webbuilder_build", "project": project, "raw_message": text})
pa = get_pending(sid) or {}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_webbuilder_confirmation(project, text),
"needs_confirmation": True,
"pending_action": _pending_summary(pa),
}
return await handle_export_intent(message, session_id=session_id, confirm_action_id=confirm_action_id)
async def handle_export_intent(
message: str,
session_id: str | None = None,
confirm_action_id: str | None = None,
) -> dict[str, Any] | None:
"""Return Herman-shaped dict when export flow applies, else None."""
sid = (session_id or "").strip() or "default"
text = (message or "").strip()
if not text:
return None
pending = get_pending(sid)
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
clear_pending(sid)
return await execute_pending_action(pending)
if pending:
if is_confirmation_yes(text):
clear_pending(sid)
return await execute_pending_action(pending)
if is_confirmation_no(text):
clear_pending(sid)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
"needs_confirmation": False,
}
if pending.get("awaiting") == "location":
loc = _detect_location(text)
if loc:
merged = dict(pending)
merged["params"] = dict(pending.get("params") or {})
merged["params"]["country"] = loc["country"]
merged["params"]["q"] = loc.get("q", "")
merged["location_label"] = loc["label"]
merged.pop("awaiting", None)
merged.pop("incomplete", None)
merged.pop("missing", None)
action_id = store_pending(sid, merged)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(merged),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": merged.get("params"),
"location_label": merged.get("location_label"),
"entity_label": merged.get("entity_label"),
},
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question({"missing": ["locatie"]}),
"needs_confirmation": True,
"clarification": True,
}
if wants_export_search(text):
parsed = parse_export_search(text)
if parsed.get("incomplete"):
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question(parsed),
"needs_confirmation": True,
"clarification": True,
}
action_id = store_pending(sid, parsed)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": parsed.get("params"),
"location_label": parsed.get("location_label"),
"entity_label": parsed.get("entity_label"),
},
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Ik wacht nog op bevestiging: {build_confirmation_question(pending)}\n\n"
"Zeg **ja** of **nee**, of stel een nieuwe zoekopdracht."
),
"needs_confirmation": True,
"pending_action": {
"id": pending.get("id"),
"type": pending.get("type", "export_intel_search"),
"params": pending.get("params"),
"location_label": pending.get("location_label"),
"entity_label": pending.get("entity_label"),
},
}
if not wants_export_search(text):
return None
parsed = parse_export_search(text)
if parsed.get("incomplete"):
store_pending(sid, {**parsed, "type": "export_intel_search", "awaiting": "location"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question(parsed),
"needs_confirmation": True,
"clarification": True,
}
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": parsed.get("params"),
"location_label": parsed.get("location_label"),
"entity_label": parsed.get("entity_label"),
},
}
+180
View File
@@ -0,0 +1,180 @@
"""Daily Herman War Room digest → Telegram (Aïssa + Mo)."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import llm_router
log = logging.getLogger("cockpit.warroom_digest")
def _recipients() -> list[tuple[str, str]]:
"""(chat_id, display_name)"""
return [
(str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), "Aïssa"),
(str(getattr(settings, "HERMES_CTO_CHAT_ID", None) or "789036463"), "Mo"),
]
def collect_warroom_day(*, hours: int = 24, limit: int = 120) -> list[dict[str, Any]]:
hours = max(1, min(int(hours), 72))
rows = fetch_all(
"""
SELECT m.id, m.sender_type, m.agent_key, m.username, m.content, m.created_at, r.kind, r.title
FROM warroom_messages m
JOIN warroom_rooms r ON r.id = m.room_id
WHERE m.created_at >= NOW() - make_interval(hours => %s)
AND r.kind = 'warroom'
ORDER BY m.created_at ASC
LIMIT %s
""",
(hours, limit),
)
out = []
for r in rows or []:
item = dict(r)
if item.get("created_at") and hasattr(item["created_at"], "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def _transcript(messages: list[dict[str, Any]], *, max_chars: int = 6000) -> str:
lines: list[str] = []
for m in messages:
st = m.get("sender_type")
if st == "user":
who = m.get("username") or "user"
elif st == "agent":
who = m.get("agent_key") or "agent"
else:
who = "system"
content = (m.get("content") or "").strip().replace("\n", " ")
if not content:
continue
lines.append(f"- {who}: {content[:280]}")
text = "\n".join(lines)
return text[-max_chars:]
async def generate_digest_text(messages: list[dict[str, Any]]) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if not messages:
return (
f"📋 War Room dagrapport {day}\n\n"
"Geen activiteit in #war-room vandaag. Agents stonden klaar; geen overleg gelogd."
)
transcript = _transcript(messages)
system = (
"Je bent Herman, Co-CEO van Foodlinkk. Schrijf een beknopte Nederlandse "
"Telegram-samenvatting van het War Room-overleg voor Aïssa (CEO) en Mo (CTO).\n"
"Structuur:\n"
f"1) Kop: 📋 War Room dagrapport {day}\n"
"2) 3–6 bullets: wat agents bespraken (pipeline, deals, acties, blockers)\n"
"3) Sectie 'Acties / open punten' met concrete next steps\n"
"4) 1 zin afsluiting\n"
"Max ~350 woorden. Geen @zelf-prefix. Geen 'agents staan uit'."
)
user = f"War Room transcript (nieuwste onderaan):\n{transcript}"
reply, _meta = await llm_router.chat_messages(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
timeout=90.0,
user=str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"),
max_tokens=900,
temperature=0.3,
)
text = (reply or "").strip()
if not text.startswith("📋"):
text = f"📋 War Room dagrapport {day}\n\n{text}"
return text[:3500]
async def _deliver_telegram(chat_id: str, name: str, digest: str) -> dict[str, Any]:
"""Ask Hermes agent to deliver the digest into that Telegram chat."""
system = (
f"Je bent Herman. Bezorg het volgende War Room-dagrapport NU via Telegram "
f"aan {name} (chat_id={chat_id}). Stuur de tekst zo letterlijk mogelijk. "
"Bevestig kort of verzonden."
)
try:
reply, meta = await llm_router.chat_messages(
[
{"role": "system", "content": system},
{"role": "user", "content": digest},
],
timeout=120.0,
user=str(chat_id),
max_tokens=200,
temperature=0.1,
)
return {
"ok": True,
"chat_id": chat_id,
"name": name,
"confirm": (reply or "")[:240],
"provider": (meta or {}).get("provider_type"),
}
except Exception as exc:
log.exception("warroom digest deliver failed %s", chat_id)
return {"ok": False, "chat_id": chat_id, "name": name, "error": str(exc)}
def _persist(digest: str, message_count: int, deliveries: list[dict[str, Any]]) -> None:
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
"herman",
"warroom_digest",
"warroom_daily_digest",
"War Room dagrapport verstuurd",
digest[:4000],
"completed",
"telegram",
__import__("json").dumps(
{
"message_count": message_count,
"deliveries": deliveries,
}
),
),
)
except Exception:
log.exception("persist digest event failed")
try:
from app.services import agent_chat
agent_chat.post_system_line(
"📤 Dagrapport verstuurd naar Aïssa + Mo via Telegram.\n\n" + digest[:1500],
agent_key="herman",
metadata={"kind": "warroom_digest", "deliveries": deliveries},
)
except Exception:
log.exception("post digest to warroom failed")
async def run_daily_digest(*, hours: int = 24, deliver: bool = True) -> dict[str, Any]:
messages = collect_warroom_day(hours=hours)
digest = await generate_digest_text(messages)
deliveries: list[dict[str, Any]] = []
if deliver:
for chat_id, name in _recipients():
deliveries.append(await _deliver_telegram(chat_id, name, digest))
_persist(digest, len(messages), deliveries)
return {
"ok": True,
"message_count": len(messages),
"digest": digest,
"deliveries": deliveries,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
+267
View File
@@ -0,0 +1,267 @@
"""Web Builder agent — websites bouwen via Hermes/agy API op VM107."""
from __future__ import annotations
import os
import re
from typing import Any
import httpx
from app.config import settings
from app.services.agent_events_log import log_agent_event
HERMES_BUILD_URL = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798").rstrip("/")
HERMES_PREVIEW_BASE = os.getenv("HERMES_PREVIEW_BASE", "http://10.4.7.27:8080").rstrip("/")
WEBSITE_KEYWORDS = (
"maak website",
"maak een website",
"bouw website",
"bouw een website",
"genereer website",
"website voor",
"website bouwen",
"landingspagina",
"landing page",
"webshop",
"webpagina",
"nieuwe site",
"nieuwe website",
"/website",
"web builder",
"webbuilder",
"agy",
"antigravity",
"anti gravity",
"anti-gravity",
"site maken",
"maak een site",
"bouw een site",
"website laten maken",
"laat agy",
"via agy",
)
PROJECT_RE = re.compile(
r"(?:website|site|project)\s+(?:voor\s+|called\s+|genaamd\s+)?['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
re.I,
)
FOR_RE = re.compile(
r"\bvoor\s+['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
re.I,
)
def wants_website(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in WEBSITE_KEYWORDS)
def slugify(name: str) -> str:
s = (name or "").strip().lower()
s = re.sub(r"[^a-z0-9_-]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
return s[:64] or "website"
def extract_project_name(raw: str) -> str:
t = (raw or "").strip()
m = PROJECT_RE.search(t)
if m:
return slugify(m.group(1))
m = FOR_RE.search(t)
if m:
name = m.group(1)
name = re.split(r"\s+(?:met|with|incl|including)\b", name, maxsplit=1, flags=re.I)[0]
return slugify(name)
for token in t.split():
clean = slugify(token)
if len(clean) >= 3 and clean not in (
"maak", "bouw", "website", "site", "voor", "een", "the", "and", "met",
"simpele", "landing", "page", "pagina", "landingspagina",
):
return clean
return slugify(t[:40]) or "website"
def extract_build_prompt(raw: str, project: str) -> str:
body = (raw or "").strip()
lower = body.lower()
for k in WEBSITE_KEYWORDS:
if lower.startswith(k):
rest = body[len(k) :].strip(" :,-")
if rest:
return rest
return (
f"Bouw een complete, moderne, responsive website voor project '{project}'. "
f"Opdracht: {body}. "
"Gebruik index.html, style.css, script.js en een assets/ map. "
"Foodlinkk halal kant-en-klaar food branding waar passend."
)
async def _hermes_get(path: str, timeout: float = 30.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{HERMES_BUILD_URL}{path}")
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
return data
async def _hermes_post(path: str, payload: dict[str, Any], timeout: float = 60.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{HERMES_BUILD_URL}{path}", json=payload)
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
data.setdefault("detail", resp.text[:500])
return data
async def list_projects() -> dict[str, Any]:
return await _hermes_get("/api/projects")
async def get_build_status(project: str) -> dict[str, Any]:
slug = slugify(project)
return await _hermes_get(f"/api/build/{slug}/status")
async def build_from_message(raw: str, *, channel: str = "herman") -> dict[str, Any]:
project = extract_project_name(raw)
prompt = extract_build_prompt(raw, project)
log_agent_event(
"webbuilder",
"website_build_start",
f"Start build: {project}",
prompt[:4000],
channel=channel,
metadata={"project": project, "prompt": prompt[:500]},
status="running",
)
data = await _hermes_post(
"/api/build",
{"project": project, "prompt": prompt},
timeout=45.0,
)
if not data.get("ok", True) and data.get("detail"):
log_agent_event(
"webbuilder",
"website_build_error",
f"Build mislukt: {project}",
str(data.get("detail", data))[:2000],
channel=channel,
metadata={"project": project},
status="error",
)
raise RuntimeError(str(data.get("detail") or data))
job_id = data.get("job_id") or project
preview = data.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
nas_path = data.get("nas_path") or f"//10.4.7.11/share/Websites/{project}/"
log_agent_event(
"webbuilder",
"website_build_running",
f"agy bezig: {project}",
f"Job {job_id}\nPreview: {preview}\nNAS: {nas_path}",
channel=channel,
metadata={
"project": project,
"job_id": job_id,
"preview_url": preview,
"nas_path": nas_path,
"correlation_id": job_id,
},
status="running",
)
return {
"project": project,
"job_id": job_id,
"preview_url": preview,
"nas_path": nas_path,
"prompt": prompt,
"hermes_status": data.get("status", "running"),
}
async def poll_until_done(project: str, *, channel: str = "herman", timeout: float = 1800.0) -> dict[str, Any]:
"""Poll Hermes build status until completed or failed."""
import asyncio
slug = slugify(project)
elapsed = 0.0
interval = 5.0
last_log = ""
while elapsed < timeout:
st = await get_build_status(slug)
status = str(st.get("status") or "unknown")
msg = str(st.get("message") or status)
if msg != last_log:
log_agent_event(
"webbuilder",
"website_build_progress",
msg[:255],
(st.get("log_tail") or "")[:4000],
channel=channel,
metadata={"project": slug, "status": status},
status="running" if status in ("running", "queued") else status,
)
last_log = msg
if status == "completed":
files = st.get("files") or []
preview = st.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
log_agent_event(
"webbuilder",
"website_build_done",
f"Website klaar: {slug}",
f"Bestanden: {', '.join(files[:8])}\nPreview: {preview}",
channel=channel,
metadata={
"project": slug,
"preview_url": preview,
"files": files,
"for_herman": True,
"source_agent": "webbuilder",
},
status="completed",
)
return {**st, "project": slug, "preview_url": preview}
if status == "failed":
detail = str(st.get("error") or st.get("message") or "Build mislukt")
log_agent_event(
"webbuilder",
"website_build_error",
f"Build mislukt: {slug}",
detail[:2000],
channel=channel,
metadata={"project": slug},
status="error",
)
raise RuntimeError(detail)
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(f"Build timeout voor {slug} na {int(timeout)}s")
async def generate_from_message(raw: str, *, channel: str = "herman", wait: bool = True) -> dict[str, Any]:
started = await build_from_message(raw, channel=channel)
if wait:
finished = await poll_until_done(started["project"], channel=channel)
started.update(finished)
return started
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#0a0e14" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
<title>{% block title %}{{ page_title }} · Cucina{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json?v=2" />
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=4" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=3" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<script defer src="/static/js/alpine.min.js?v=1"></script>
<script defer src="/static/js/htmx.min.js?v=1"></script>
{% block extra_head %}{% endblock %}
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'persona-ceo': persona === 'ceo', 'persona-cto': persona === 'cto' }">
<div class="app-shell">
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
<div class="app-sidebar-brand">
<p class="brand-title">Cucina · Foodlinkk</p>
<p class="brand-name" x-text="persona === 'ceo' ? 'CEO Dashboard' : 'CTO Dashboard'">CEO Dashboard</p>
</div>
<div class="persona-switch" role="tablist" aria-label="Dashboard modus">
<button type="button" role="tab" class="persona-btn persona-btn-ceo"
:class="{ active: persona === 'ceo' }" @click="setPersona('ceo')"
:aria-selected="persona === 'ceo'" data-i18n="nav.persona_ceo">CEO · Business</button>
<button type="button" role="tab" class="persona-btn persona-btn-cto"
:class="{ active: persona === 'cto' }" @click="setPersona('cto')"
:aria-selected="persona === 'cto'" data-i18n="nav.persona_cto">CTO · Tech</button>
</div>
<!-- ═══ CEO · BUSINESS ═══ -->
<template x-if="persona === 'ceo'">
<div class="persona-nav">
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.ceo_home">CEO Overzicht</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.business">Business</div>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
</div>
</div>
</template>
<!-- ═══ CTO · TECH ═══ -->
<template x-if="persona === 'cto'">
<div class="persona-nav">
<div class="app-sidebar-group">
<a href="/cto" class="nav-pill nav-pill-ops {% if request.url.path == '/cto' %}active{% endif %}" data-i18n="nav.cto_home">CTO Overzicht</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.devops">DevOps</div>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.tools_api">Tools API</a>
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="nav-pill nav-pill-analytics" data-i18n="nav.grafana">Grafana</a>
</div>
</div>
</template>
</aside>
<div class="mobile-nav-overlay" x-show="navOpen" @click="navOpen=false" x-cloak></div>
<div class="app-main">
<header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen">☰</button>
<span class="page-tip">
<span class="persona-badge" :class="persona === 'ceo' ? 'persona-badge-ceo' : 'persona-badge-cto'" x-text="persona === 'ceo' ? 'CEO' : 'CTO'"></span>
Cucina · Foodlinkk
</span>
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
<option value="nl">NL</option>
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
</div>
</div>
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/i18n.js?v=4"></script>
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script>
window.FoodlinkkPersona = (function () {
var CTO_PREFIXES = ['/cto', '/ops', '/agents', '/browser', '/monitor', '/studio', '/voice', '/settings'];
function isCtoPath(path) {
return CTO_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
}
function detect(path) {
var stored = localStorage.getItem('foodlinkk-persona');
if (stored === 'ceo' || stored === 'cto') return stored;
return isCtoPath(path) ? 'cto' : 'ceo';
}
return { isCtoPath: isCtoPath, detect: detect };
})();
function appShell() {
return {
locale: 'nl',
persona: 'ceo',
drawerOpen: false,
navOpen: false,
showInstall: false,
_deferredPrompt: null,
clockDate: '—',
clockTime: '—',
_offset: 0,
async initApp() {
this.persona = window.FoodlinkkPersona.detect(location.pathname);
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock();
this.initPwa();
},
setPersona(p) {
if (p === this.persona) return;
this.persona = p;
localStorage.setItem('foodlinkk-persona', p);
var path = location.pathname;
var onCto = window.FoodlinkkPersona.isCtoPath(path);
if (p === 'ceo' && onCto) { location.href = '/'; return; }
if (p === 'cto' && !onCto) { location.href = '/cto'; return; }
},
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
},
async initClock() {
try {
const r = await fetch('/api/server-time').then(x => x.json());
this._offset = new Date(r.utc).getTime() - Date.now();
} catch (e) { this._offset = 0; }
const tick = () => {
const d = new Date(Date.now() + this._offset);
this.clockDate = d.toLocaleDateString('nl-NL', { timeZone: 'Europe/Amsterdam', weekday: 'short', day: 'numeric', month: 'short' });
this.clockTime = d.toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
tick();
setInterval(tick, 1000);
},
initPwa() {
if (window.matchMedia('(display-mode: standalone)').matches) return;
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.showInstall = !localStorage.getItem('pwa-dismiss');
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this._deferredPrompt = e;
this.showInstall = true;
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
}
},
async installPwa() {
if (this._deferredPrompt) {
this._deferredPrompt.prompt();
await this._deferredPrompt.userChoice;
this._deferredPrompt = null;
this.showInstall = false;
return;
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
} else {
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
}
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
}
};
}
</script>
<script>
window.addEventListener('load', function () {
setTimeout(function () {
if (window.Alpine) return;
document.querySelectorAll('[x-cloak]').forEach(function (el) { el.removeAttribute('x-cloak'); });
document.body.classList.add('alpine-missing');
console.error('Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).');
}, 2500);
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+475
View File
@@ -0,0 +1,475 @@
window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function parseContent(content) {
var parts = (content || '').split(/\n---\n/);
var ai = parts[0] || '';
var summary = '', actions = [], longTerm = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
return { summary: summary, actions: actions, longTerm: longTerm };
}
function typewriter(el, text, speed) {
if (!el) return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
var i = 0;
typewriterTimer = setInterval(function () {
if (i < text.length) { el.textContent += text.charAt(i); i++; }
else clearInterval(typewriterTimer);
}, speed || 8);
}
function countUp(el, end, prefix, suffix) {
if (!el) return;
prefix = prefix || ''; suffix = suffix || '';
var start = 0, dur = 600, t0 = performance.now();
function step(t) {
var p = Math.min(1, (t - t0) / dur);
var v = Math.round(start + (end - start) * p);
el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function chartColors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function renderPipeline(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
if (charts.pipeline) charts.pipeline.destroy();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderSentiment(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; });
if (!files.length) counts.neutral = 1;
var c = chartColors();
if (charts.sentiment) charts.sentiment.destroy();
charts.sentiment = new Chart(canvas, {
type: 'doughnut',
data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
});
}
function renderWords(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
if (charts.words) charts.words.destroy();
charts.words = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderAgents(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var map = {};
(stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
var labels = Object.keys(map), c = chartColors();
if (charts.agents) charts.agents.destroy();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } },
});
}
function eqBars() {
return '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function sparklineSvg(values, trend) {
if (!values || !values.length) return '';
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = max - min || 1;
var pts = values.map(function (v, i) {
var x = (i / (values.length - 1 || 1)) * 100;
var y = 100 - ((v - min) / range) * 80 - 10;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var color = trend === 'down' ? '#fb7185' : '#4ade80';
return '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
}
function stockEqBars(trend) {
var cls = trend === 'down' ? ' hm-eq-down' : '';
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function renderStocksMini(container, stats) {
if (!container) return;
var stocks = (stats.market_stocks || []).slice(0, 4);
var summary = stats.market_summary || {};
if (!stocks.length) {
container.innerHTML = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
'<strong>' + (s.symbol || s.name) + '</strong> ' +
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
}
function renderStocks(container, stats) {
if (!container) return;
var stocks = stats.market_stocks || [];
var summary = stats.market_summary || {};
var meta = document.getElementById('market-updated-at');
if (meta) {
var avg = summary.avg_change_pct;
meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
}
if (!stocks.length) {
container.innerHTML = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
var up = pct >= 0;
var price = s.price != null ? Number(s.price).toFixed(2) : '—';
var cur = s.currency || 'EUR';
return '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
if (!container) return;
var items = stats.regulation_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderTrends(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.market_trends || [];
var c = chartColors();
if (charts.trends) charts.trends.destroy();
if (!rows.length) return;
charts.trends = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
datasets: [{
label: 'Kans %',
data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
borderRadius: 6,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
scales: {
y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
},
},
});
}
function kpiValue(id, stats) {
var summary = stats.market_summary || {};
var best = summary.best_performer || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
case 'wholesalers': return String(stats.wholesalers || 0);
case 'trending_food': return String(trendCount || 0);
case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
case 'pending_approvals': return String(stats.pending_approvals || 0);
case 'deals': return String(stats.deals || 0);
case 'products': return String(stats.products || 0);
case 'suppliers': return String(stats.suppliers || 0);
case 'nas_docs': return String(stats.nas_docs || 0);
case 'promo_campaigns': return String(stats.promo_campaigns || 0);
case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
default: return '—';
}
}
function kpiPct(id, stats) {
var summary = stats.market_summary || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '72%';
case 'clients_active':
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
case 'crm_partnerships': return '45%';
case 'supermarkets': return '88%';
case 'trending_food': return Math.min(95, trendCount * 10) + '%';
case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
case 'pending_approvals': return '30%';
case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
default: return '50%';
}
}
function renderKpis(container, stats, selectedKpis) {
if (!container) return;
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
var order = selectedKpis || window._dashboardKpis ||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
};
});
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
return;
}
container.className = 'hm-neo-kpi-row';
container.innerHTML = items.map(function (it) {
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : inner;
}).join('');
}
function renderRssFeed(container, stats) {
if (!container) return;
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
var count = stats.rss_items || items.length;
var head = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
' · <a href="/marketing">Marketing Hub →</a></div>';
if (!items.length) {
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
var btn = container.querySelector('#btn-rss-refresh-dash');
if (btn) btn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
return;
}
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
var cat = (r.category || 'feed').toUpperCase();
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
'<span class="hm-rss-cat">' + cat + '</span>' +
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
}).join('') + '</div>';
var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
if (refreshBtn) refreshBtn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
}
function renderRetail(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
if (!container) return;
var ms = stats.milestones_pending || [];
if (!ms.length) {
container.innerHTML = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
return;
}
container.innerHTML = ms.map(function (m) {
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
}).join('');
}
function renderExecutiveSummary(container, stats) {
if (!container) return;
var items = [
{ icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
{ icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
{ icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
{ icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
{ icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
{ icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
ms.forEach(function (m) {
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
});
html += '</ul>';
}
var pending = stats.pending_approval_requests || [];
if (pending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
pending.forEach(function (p) {
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8);
var actEl = document.getElementById('briefing-actions-list');
if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Genereer dagrapport voor actiepunten</li>';
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
}
function renderLiveSummary(digest, instant) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var summary = digest.summary || '';
if (sumEl) {
if (instant) {
if (typewriterTimer) clearInterval(typewriterTimer);
sumEl.textContent = summary || 'Live samenvatting laden…';
} else {
typewriter(sumEl, summary, 4);
}
}
if (actEl) {
var actions = digest.actions || [];
actEl.innerHTML = actions.length
? actions.map(function (a) { return '<li>' + a + '</li>'; }).join('')
: '<li>Geen urgente acties — check agents & retail</li>';
}
if (longEl) {
var lt = digest.long_term || [];
longEl.innerHTML = lt.length
? lt.map(function (a) { return '<li>' + a + '</li>'; }).join('')
: '<li>Halal kant-en-klaar partnerships schalen</li>';
}
if (meta && digest.updated_at) {
var ts = String(digest.updated_at).substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live samenvatting: ' + ts + ' UTC' + n;
}
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest) {
if (!root || !stats) return;
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, true);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, true);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), stats);
var actEl = document.getElementById('briefing-activity-log');
if (actEl) {
var log = stats.activity_log || [];
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
}
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats, vizMode);
renderText(root, content, createdAt);
}
return {
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
})();
+872
View File
@@ -0,0 +1,872 @@
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import market_stocks, llm_router
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "", params: tuple = ()) -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}", params or None)
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def serialize_stats(data: dict[str, Any]) -> dict[str, Any]:
def _default(o: Any) -> Any:
if hasattr(o, "isoformat"):
return o.isoformat()
if hasattr(o, "__float__"):
try:
return float(o)
except (TypeError, ValueError):
pass
return str(o)
return json.loads(json.dumps(data, default=_default))
def _briefing_step(
phase: str,
source: str,
agent: str,
status: str,
message: str,
detail: str | None = None,
) -> dict[str, Any]:
return {
"type": "step",
"phase": phase,
"source": source,
"agent": agent,
"status": status,
"message": message,
"detail": detail,
"at": datetime.now(timezone.utc).isoformat(),
}
def _collect_crm_core(data: dict[str, Any]) -> None:
data["clients"] = _safe_count("clients")
data["deals"] = _safe_count("deals")
data["products"] = _safe_count("products")
data["suppliers"] = _safe_count("suppliers")
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
def _collect_agent_queue(data: dict[str, Any]) -> None:
try:
data["pending_approval_requests"] = fetch_all(
"""SELECT id, agent_key, action_type, title, query_payload, created_at
FROM agent_action_requests WHERE status = 'pending'
ORDER BY created_at ASC LIMIT 15"""
)
data["pending_approvals"] = len(data["pending_approval_requests"])
except Exception:
data["pending_approval_requests"] = []
try:
data["recent_executed_actions"] = fetch_all(
"""SELECT id, agent_key, action_type, title, result, executed_at, approved_by
FROM agent_action_requests
WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
ORDER BY executed_at DESC LIMIT 12"""
)
except Exception:
data["recent_executed_actions"] = []
def _collect_projects_ops(data: dict[str, Any]) -> None:
try:
data["project_assets_recent"] = fetch_all(
"""SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
FROM project_assets pa
JOIN cockpit_projects cp ON cp.id = pa.project_id
WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
ORDER BY pa.created_at DESC LIMIT 15"""
)
except Exception:
data["project_assets_recent"] = []
try:
data["ops_maintenance_open"] = fetch_all(
"""SELECT severity, title, body, created_at FROM ops_maintenance_notes
WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
)
except Exception:
data["ops_maintenance_open"] = []
try:
data["config_backups_recent"] = fetch_all(
"""SELECT status, message, commit_ref, created_at FROM config_backups
ORDER BY created_at DESC LIMIT 5"""
)
except Exception:
data["config_backups_recent"] = []
try:
data["sysops_activity_24h"] = fetch_all(
"""SELECT action_type, title, body, commit_ref, files_changed, status, created_at
FROM sysops_activity
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["sysops_activity_24h"] = []
try:
data["sysops_events_24h"] = fetch_all(
"""SELECT event_type, title, body, status, created_at, metadata
FROM agent_events
WHERE LOWER(agent_name) = 'sysops'
AND created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 15"""
)
except Exception:
data["sysops_events_24h"] = []
def _collect_pipeline_events(data: dict[str, Any]) -> None:
try:
data["deals_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["deals_by_stage"] = []
try:
data["recent_clients"] = fetch_all(
"SELECT name, stage, email, created_at FROM clients ORDER BY created_at DESC LIMIT 5"
)
except Exception:
data["recent_clients"] = []
try:
data["recent_events"] = fetch_all(
"""SELECT agent_name, event_type, title, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 12"""
)
except Exception:
data["recent_events"] = []
try:
data["recent_handoffs"] = fetch_all(
"""SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
FROM agent_handoffs
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["recent_handoffs"] = []
try:
data["pending_items"] = fetch_all(
"""SELECT agent_name, title, event_type, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at DESC LIMIT 8"""
)
except Exception:
data["pending_items"] = []
def _collect_nas_analytics(data: dict[str, Any]) -> None:
try:
row = fetch_one(
"""SELECT COUNT(*) AS docs, COALESCE(SUM(word_count), 0) AS words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
FROM document_analytics"""
)
data["nas_docs"] = int(row["docs"] or 0) if row else 0
data["nas_words"] = int(row["words"] or 0) if row else 0
data["nas_sentiment"] = round(float(row["avg_sentiment"] or 0), 3) if row else 0.0
except Exception:
data["nas_docs"] = data["nas_words"] = 0
data["nas_sentiment"] = 0.0
try:
data["nas_files"] = fetch_all(
"""SELECT filename, doc_type, sentiment_label, word_count
FROM document_analytics ORDER BY analyzed_at DESC LIMIT 8"""
)
except Exception:
data["nas_files"] = []
try:
data["top_words"] = fetch_all(
"""SELECT lemma, SUM(count) AS total FROM document_word_counts
WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 10"""
)
except Exception:
data["top_words"] = []
try:
data["calendar_events"] = fetch_all(
"""SELECT ce.title, ce.starts_at, ce.ends_at, c.name AS client_name
FROM calendar_events ce
LEFT JOIN clients c ON c.id = ce.client_id
WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
AND ce.starts_at <= NOW() + INTERVAL '7 days'
ORDER BY ce.starts_at ASC LIMIT 10"""
)
except Exception:
data["calendar_events"] = []
def _collect_retail_intel(data: dict[str, Any]) -> None:
data["supermarkets"] = _safe_count("supermarkets")
data["clients_active"] = _safe_count("clients", "stage = 'active'")
data["clients_total"] = _safe_count("clients")
data["crm_partnerships"] = _safe_count("supermarkets", "partnership_status = 'active'")
data["wholesalers"] = _safe_count("wholesalers")
data["rss_bookmarks"] = _safe_count("rss_bookmarks")
data["promo_campaigns"] = _safe_count("promo_campaigns", "status = 'active'")
try:
data["top_opportunities"] = fetch_all(
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros
JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
)
except Exception:
data["top_opportunities"] = []
try:
data["milestones_pending"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.target_date, sm.value_eur,
s.name AS store_name, s.chain, c.name AS client_name
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
LEFT JOIN clients c ON c.id = sm.client_id
WHERE sm.status IN ('pending', 'in_progress')
ORDER BY sm.target_date ASC NULLS LAST, sm.created_at DESC LIMIT 8"""
)
except Exception:
data["milestones_pending"] = []
try:
data["milestones_recent"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.completed_at, sm.value_eur,
s.name AS store_name, s.chain
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
ORDER BY sm.created_at DESC LIMIT 5"""
)
except Exception:
data["milestones_recent"] = []
def _collect_rss_market(data: dict[str, Any]) -> None:
try:
data["rss_highlights"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE i.title ILIKE ANY (ARRAY['%kant%','%maaltijd%','%supermarkt%','%retail%','%halal%','%jumbo%','%meal%'])
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["rss_highlights"] = []
try:
data["market_trends"] = fetch_all(
"SELECT trend_name, description, opportunity_score FROM market_trends ORDER BY updated_at DESC LIMIT 4"
)
except Exception:
data["market_trends"] = []
try:
quotes = market_stocks.fetch_retail_quotes()
data["market_stocks"] = quotes
data["market_summary"] = market_stocks.market_summary(quotes)
except Exception:
data["market_stocks"] = []
data["market_summary"] = {}
try:
data["regulation_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('regelgeving', 'cbs')
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["regulation_highlights"] = []
try:
data["trending_food"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
)
except Exception:
data["trending_food"] = []
if not data.get("trending_food"):
try:
data["trending_food"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 12"""
)
except Exception:
data["trending_food"] = []
try:
data["food_market_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
if not data.get("food_market_highlights"):
data["food_market_highlights"] = list(data.get("trending_food") or [])[:10]
try:
data["rss_live"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
)
except Exception:
data["rss_live"] = list(data.get("trending_food") or [])
data["rss_items"] = _safe_count("rss_items")
def collect_briefing_data() -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
_collect_crm_core(data)
_collect_agent_queue(data)
_collect_projects_ops(data)
_collect_pipeline_events(data)
_collect_nas_analytics(data)
_collect_retail_intel(data)
_collect_rss_market(data)
data["activity_log"] = _build_activity_log(data)
return data
async def stream_daily_briefing():
"""Yield SSE step events while building the CEO daily report."""
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
_collect_crm_core(data)
yield _briefing_step(
"crm", "PostgreSQL", "crm", "ok",
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
"tables: clients, deals, products, suppliers",
)
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
_collect_agent_queue(data)
pending = data.get("pending_approval_requests") or []
yield _briefing_step(
"agents", "PostgreSQL", "herman", "ok",
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
"table: agent_action_requests",
)
for row in pending[:4]:
agent = row.get("agent_key") or "agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
)
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
_collect_projects_ops(data)
yield _briefing_step(
"projects", "PostgreSQL", "sysops", "ok",
f"{len(data.get('project_assets_recent') or [])} project assets · "
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
"tables: project_assets, sysops_activity, config_backups",
)
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
_collect_pipeline_events(data)
stages = len(data.get("deals_by_stage") or [])
events = data.get("recent_events") or []
yield _briefing_step(
"pipeline", "PostgreSQL", "finance", "ok",
f"{stages} pipeline stages · {len(events)} recente agent-events",
"tables: deals, agent_events",
)
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
_collect_nas_analytics(data)
yield _briefing_step(
"nas", "NAS analytics", "knowledge", "ok",
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
"tables: document_analytics, document_word_counts",
)
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
_collect_retail_intel(data)
opp = data.get("top_opportunities") or []
yield _briefing_step(
"retail", "Retail 360", "retail", "ok",
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} open milestones",
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
)
if opp:
top = opp[0]
yield _briefing_step(
"agent_msg", "agent_mesh", "retail", "agent",
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
)
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
_collect_rss_market(data)
trend_n = len(data.get("trending_food") or [])
yield _briefing_step(
"rss", "RSS feeds", "marketing", "ok",
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
"tables: rss_items, rss_feeds, promo_campaigns",
)
yield _briefing_step(
"agent_msg", "agent_mesh", "marketing", "agent",
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
)
data["activity_log"] = _build_activity_log(data)
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
seen: set[str] = set()
mesh_events = data.get("recent_events") or []
for ev in mesh_events[:10]:
agent = (ev.get("agent_name") or "agent").lower()
if agent in seen:
continue
seen.add(agent)
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
)
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
yield _briefing_step(
"agent_msg", "agent_mesh", "herman", "agent",
f"Herman → {agent_key}: briefing context gedeeld",
)
yield _briefing_step(
"agent_mesh", "Agent mesh", "herman", "ok",
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
)
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
"Herman schrijft executive samenvatting met AI…",
)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
except (asyncio.TimeoutError, Exception) as exc:
ai_part = ""
yield _briefing_step(
"ai", "Ollama", "herman", "warn",
"AI timeout — gebruik template samenvatting",
str(exc)[:120],
)
else:
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
)
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
template = build_template_report(data)
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
stats = serialize_stats(data)
yield _briefing_step("compose", "PostgreSQL", "herman", "ok", "Dagrapport opgeslagen in daily_briefings")
yield {
"type": "done",
"content": content,
"stats": stats,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
def _build_activity_log(data: dict[str, Any]) -> list[str]:
lines: list[str] = []
for row in data.get("pending_approval_requests") or []:
agent = row.get("agent_key") or "agent"
action = row.get("action_type") or "actie"
title = row.get("title") or ""
if action == "maintenance_scan":
lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
elif action == "config_backup":
lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
else:
lines.append(f"⏳ {agent} wacht op goedkeuring ({action}): {title}")
for row in data.get("recent_executed_actions") or []:
lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
for row in data.get("project_assets_recent") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
lines.append(
f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
)
for row in data.get("ops_maintenance_open") or []:
lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
for row in data.get("config_backups_recent") or []:
lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
for row in data.get("sysops_activity_24h") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
for row in data.get("sysops_events_24h") or []:
if (row.get("event_type") or "") == "gitea_sync":
continue
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
for row in (data.get("recent_events") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
for row in (data.get("recent_handoffs") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"🔗 {row.get('from_agent')} → {row.get('to_agent')}: {row.get('handoff_type')} ({ts_s})"
)
return lines[:25]
def _strip_activity_prefix(line: str) -> str:
for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
if line.startswith(prefix):
return line[len(prefix) :].strip()
return line.strip()
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""Live samenvatting zonder LLM — volgt cockpit-activiteit (polling / websocket)."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
ms = data.get("milestones_pending") or []
pending_n = int(data.get("pending_approvals") or 0)
executed = data.get("recent_executed_actions") or []
sysops = data.get("sysops_activity_24h") or []
summary_parts = [
(
f"Vandaag ({data['date']}) staat er €{data['pipeline_eur']:,.0f} in je pipeline, "
f"{data.get('crm_partnerships', 0)} actieve supermarkt-partnerships en "
f"{pending_n} open goedkeuring{'en' if pending_n != 1 else ''}."
)
]
if activity:
recent = [_strip_activity_prefix(a) for a in activity[:4]]
summary_parts.append("Recent in het Cockpit: " + "; ".join(recent[:3]) + ".")
elif executed:
row = executed[0]
summary_parts.append(
f"Laatste uitgevoerde actie: {row.get('agent_key')} — {row.get('title')}."
)
elif sysops:
row = sysops[0]
summary_parts.append(f"Laatste SysOps: {row.get('title')}.")
elif data.get("recent_events"):
ev = data["recent_events"][0]
summary_parts.append(
f"Laatste agent-event: {ev.get('agent_name')} — {ev.get('title')}."
)
else:
summary_parts.append(
"Nog geen nieuwe activiteit vandaag — gebruik Voice, Agents of Export Intel om Herman te voeden."
)
if opp:
top = opp[0]
summary_parts.append(
f"Top retail-kans: {top.get('chain')} · {top.get('name')} in {top.get('city')} "
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
)
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = row.get("title") or row.get("action_type") or "goedkeuring"
actions.append(f"Keur goed: @{row.get('agent_key')} — {label}")
for row in executed[:2]:
actions.append(f"Follow-up na {row.get('agent_key')}: {row.get('title')}")
for row in ms[:2]:
actions.append(f"Milestone: {row.get('title')} ({row.get('chain') or 'CRM'})")
if pending_n and not actions:
actions.append(f"Behandel {pending_n} open agent-goedkeuring(en) op het dashboard")
if not actions:
actions.extend([
"Open Retail 360 voor top halal-gap filialen",
"Check Marketing Live Feed voor kant-en-klaar trends",
])
long_term = [
"Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
"Halal kant-en-klaar listing bij regio's met hoogste demografische vraag",
"Wekelijks milestones review in Retail 360",
]
if sysops:
long_term.insert(0, f"IT Ops: {len(sysops)} SysOps-acties in de laatste 24 uur vastleggen en reviewen")
return {
"summary": " ".join(summary_parts),
"actions": actions[:6],
"long_term": long_term[:5],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"source": "live",
}
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
"",
"## KPI's",
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
f"- **Supermarkten in DB:** {data.get('supermarkets', 0)} · **CRM partnerships:** {data.get('crm_partnerships', 0)}",
f"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}",
"",
]
if data.get("top_opportunities"):
lines.extend(["## Top halal-markt kansen (Retail 360)"])
for row in data["top_opportunities"]:
score = round(float(row.get("halal_opportunity_score") or 0))
lines.append(f"- **{row.get('chain')} · {row.get('name')}** ({row.get('city')}) — score {score}/100")
lines.append("")
if data.get("milestones_pending"):
lines.extend(["## Sales milestones — open"])
for row in data["milestones_pending"]:
td = row.get("target_date")
td_s = td.isoformat()[:10] if hasattr(td, "isoformat") else str(td or "—")[:10]
lines.append(f"- [{td_s}] **{row.get('title')}** · {row.get('chain') or ''} {row.get('store_name') or ''} · €{row.get('value_eur') or '—'}")
lines.append("")
if data.get("rss_highlights"):
lines.extend(["## Kant-en-klaar & supermarkt nieuws"])
for row in data["rss_highlights"]:
lines.append(f"- [{row.get('feed_name')}] {row.get('title')}")
lines.append("")
if data.get("market_trends"):
lines.extend(["## Markt trends"])
for row in data["market_trends"]:
pct = round(float(row.get("opportunity_score") or 0) * 100)
lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}")
lines.append("")
lines.extend(["## Pipeline per stage"])
for row in data.get("deals_by_stage") or []:
lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
if not data.get("deals_by_stage"):
lines.append("- Geen deals in database.")
if data.get("calendar_events"):
lines.extend(["", "## Agenda (7 dagen)"])
for row in data["calendar_events"]:
ts = row.get("starts_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"):
lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
return "\n".join(lines)
async def _ai_executive_summary(data: dict[str, Any]) -> str:
opp_lines = ""
for row in data.get("top_opportunities") or []:
opp_lines += f"- {row.get('chain')} {row.get('name')} ({row.get('city')}): score {round(float(row.get('halal_opportunity_score') or 0))}\n"
ms_lines = ""
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
)
try:
return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
def _fallback_summary(data: dict[str, Any]) -> str:
digest = build_live_digest(data)
lines = ["## Samenvatting", digest["summary"]]
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
for a in digest["actions"]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
for a in digest["long_term"]:
lines.append(f"- {a}")
activity = data.get("activity_log") or []
if activity:
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
for entry in activity[:8]:
lines.append(f"- {_strip_activity_prefix(entry)}")
return "\n".join(lines)
def _save_briefing(content: str, data: dict[str, Any]) -> None:
safe = serialize_stats(data)
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
try:
execute(
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
(content, "herman", json.dumps(metadata)),
)
except Exception:
pass
try:
execute(
"""INSERT INTO llm_memory (category, subject, content, source, metadata, updated_at)
VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
(
f"Briefing {data['date']}",
content[:8000],
json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
),
)
except Exception:
pass
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
"completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
),
)
except Exception:
pass
async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
stats = serialize_stats(data)
stats["live_digest"] = build_live_digest(data)
return content, stats
+10
View File
@@ -0,0 +1,10 @@
{
auto_https off
admin off
}
:8643 {
tls /certs/cert.pem /certs/key.pem
encode gzip
reverse_proxy cockpit:8600
}
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD
DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ
MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP
0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL
45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f
stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3
By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg
MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw
hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs
MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d
KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN
L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2
kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC
9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV
KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x
p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/
BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV
BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag
3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg
8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u
k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64
HJOEFqjE6LCTsSnqN2da2GGm5AY=
-----END CERTIFICATE-----
+16
View File
@@ -0,0 +1,16 @@
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = 10.4.7.18
O = Foodlinkk Cockpit
[v3_req]
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = 10.4.7.18
IP.2 = 127.0.0.1
DNS.1 = localhost
DNS.2 = cockpit.foodlinkk.local
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo" x-data="ctoDashboard()" x-init="init()">
<div class="herman-hero">
<h1>CTO Dashboard <span class="live-badge">Live</span></h1>
<p class="hero-sub">Platform · agents · infra · DevOps · goedkeuringen</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/agents?tab=approvals" class="beurs-mini-chip">✓ Goedkeuringen</a>
<a href="/browser" class="beurs-mini-chip">🌐 Browser agent</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="beurs-mini-chip">📈 Grafana</a>
</div>
</div>
<section class="panel herman-briefing">
<header class="herman-briefing-header">
<div>
<h2>Platform status</h2>
<div class="briefing-meta" x-text="opsMeta">Laden…</div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
<a href="/ops" class="btn btn-pulse-green">Volledige IT Ops →</a>
</div>
</header>
<div class="hm-neo-kpi-row" style="margin-bottom:1.25rem">
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Health</div>
<div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Online nodes</div>
<div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Services</div>
<div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Open goedkeuringen</div>
<div class="hm-neo-kpi-value" x-text="pendingApprovals"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Agent events (24u)</div>
<div class="hm-neo-kpi-value" x-text="agentEvents24h"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Browser sessies (24u)</div>
<div class="hm-neo-kpi-value">{{ kpis.browser_sessions_24h }}</div>
</div>
</div>
<div class="hm-dash-two-col">
<section class="hm-dash-section" data-widget="approvals">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen</h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>.</p>
<div id="cto-approvals-list"></div>
</div>
</section>
<section class="hm-dash-section">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Maintenance</h3>
<template x-if="!maintenance.length">
<p class="empty-state">Geen open maintenance items 🎉</p>
</template>
<div class="ops-maint-list">
<template x-for="n in maintenance.slice(0, 6)" :key="n.id">
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
<strong x-text="n.title"></strong>
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
</div>
</template>
</div>
<p class="page-tip" style="margin-top:0.75rem"><a href="/ops">Alle maintenance →</a></p>
</section>
</div>
<div class="hub-grid hm-dash-feed" style="margin-top:1.25rem">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="cto-feed-badge">Live</span></h2>
<div id="cto-agent-feed">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Browser sessies</h2>
{% if browser_sessions %}
{% for s in browser_sessions[:6] %}
<div class="hub-feed-item">
<span>{{ s.created_at[11:16] if s.created_at else '' }}</span>
<span class="badge badge-agent-browser">browser</span>
{{ s.title or s.url or s.task }}
</div>
{% endfor %}
{% else %}
<p class="empty-state">Geen recente browser-sessies.</p>
{% endif %}
<p class="page-tip" style="margin-top:0.75rem"><a href="/browser">Browser agent →</a></p>
</section>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
function ctoDashboard() {
return {
loading: false,
summary: {},
meta: {},
maintenance: [],
opsMeta: 'Laden…',
pendingApprovals: {{ kpis.pending_approvals }},
agentEvents24h: 0,
_pollStop: null,
_wsStop: null,
async init() {
localStorage.setItem('foodlinkk-persona', 'cto');
await this.refresh(false);
await this.loadApprovals();
this._pollStop = CockpitLive.startPolling(() => { this.refresh(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('cto-agent-feed'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('cto-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
async refresh(toast) {
this.loading = true;
try {
const [status, maint, analytics] = await Promise.all([
fetch('/api/ops/status').then(x => x.json()).catch(() => ({})),
fetch('/api/ops/maintenance?limit=12').then(x => x.json()).catch(() => ({ items: [] })),
fetch('/analytics/api/data').then(x => x.json()).catch(() => ({})),
]);
this.summary = status || { counts: {} };
this.meta = (status.topology && status.topology.meta) || status.sources || {};
this.maintenance = (maint.items || []).filter(n => !n.resolved);
this.opsMeta = 'Sync: ' + new Date().toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam' });
if (analytics.kpis) {
this.pendingApprovals = analytics.kpis.pending_approvals ?? this.pendingApprovals;
this.agentEvents24h = analytics.kpis.agent_events ?? 0;
}
if (toast !== false) Cockpit.toast('Platform data bijgewerkt', 'success');
} catch (e) {
if (toast !== false) Cockpit.toast(e.message || 'Fout', 'error');
}
this.loading = false;
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('cto-approvals-list');
if (!el) return;
const items = r.items || [];
this.pendingApprovals = items.length;
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
}
};
}
</script>
{% endblock %}
+549
View File
@@ -0,0 +1,549 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero">
<h1>CEO Dashboard · Cucina <span class="live-badge">Live</span></h1>
<p class="hero-sub">Business overzicht · retail · deals · briefing · marketing</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="/clients" class="beurs-mini-chip">👥 Klanten</a>
<a href="/deals" class="beurs-mini-chip">💼 Deals</a>
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="/retail" class="beurs-mini-chip">🛒 Retail</a>
<a href="/analytics" class="beurs-mini-chip">📊 Analytics</a>
</div>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
<nav class="herman-dash-tabs" aria-label="Herman weergaven">
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'overview' }" @click="setHermanTab('overview')">
<span aria-hidden="true">📊</span> Overzicht
</button>
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'rss' }" @click="setHermanTab('rss')">
<span aria-hidden="true">📰</span> RSS Feeds
<span class="herman-dash-tab-badge" x-show="rssTotal > 0" x-text="rssTotal"></span>
</button>
</nav>
<header class="herman-briefing-header">
<div>
<h2>Dagelijkse briefing</h2>
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? '✓ Klaar' : '⚙ Dashboard instellen'"></button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
<a href="/analytics" class="btn">Analytics</a>
</div>
</header>
<div id="hm-live-bar" class="hm-live-bar" x-show="loading || liveLog.length" x-cloak :class="loading ? 'hm-live-bar-active' : 'hm-live-bar-done'">
<div class="hm-live-bar-head">
<span class="hm-live-dot"></span>
<strong class="hm-live-title" x-text="loading ? loadingMsg : 'Dagrapport voltooid'"></strong>
<span class="hm-live-progress" x-show="loading" x-text="liveProgress"></span>
<button type="button" class="btn btn-sm hm-live-close" x-show="!loading && liveLog.length" @click="liveLog = []">✕</button>
</div>
<ol id="hm-live-log" class="hm-live-log">
<template x-for="(entry, idx) in liveLog" :key="idx">
<li :class="'hm-live-entry hm-live-' + (entry.status || 'ok') + (entry.type === 'agent_msg' ? ' hm-live-agent' : '')">
<span class="hm-live-src" x-text="entry.source || entry.agent"></span>
<span class="hm-live-msg" x-text="entry.message"></span>
<small class="hm-live-detail" x-show="entry.detail" x-text="entry.detail"></small>
</li>
</template>
</ol>
</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" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · CEO KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's <small class="hm-widget-hint" x-show="editLayout">— kies KPI's hieronder</small></h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="executive">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Executive</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
<div id="briefing-executive-summary"></div>
</div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p>
</div>
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
<h4>📋 Herman activiteitenlog</h4>
<ul id="briefing-activity-log" class="hm-exec-list" style="margin:0;padding-left:1.1rem;font-size:0.82rem"></ul>
</div>
<div class="briefing-horizon-col">
<div class="horizon-card short">
<h4>⚡ Korte termijn</h4>
<ul id="briefing-actions-list"></ul>
</div>
<div class="horizon-card long">
<h4>🎯 Lange termijn</h4>
<ul id="briefing-longterm-list"></ul>
</div>
</div>
</div>
</section>
<section class="hm-dash-section" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col">
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub →</a></small></div>
<div id="briefing-retail"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
<div id="briefing-milestones"></div>
</div>
</div>
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
<div id="briefing-food-highlights" class="hm-highlight-scroll"></div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-dash-analytics" data-widget="analytics">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Analytics</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
</section>
<div class="hub-grid hm-dash-feed" data-widget="feed">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Feed</div>
<section class="panel hm-dash-full">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
<!-- ═══ RSS FEEDS TAB ═══ -->
<div class="herman-rss-hub" x-show="hermanTab === 'rss'" x-cloak>
<header class="herman-rss-head">
<div>
<h2>RSS Feeds</h2>
<p class="page-tip">Alle live feeds op één plek — filter op categorie of zoek op titel</p>
</div>
<div class="herman-rss-actions">
<button type="button" class="btn btn-sm btn-pulse" @click="refreshRss(true)" :disabled="rssLoading">↻ RSS ophalen</button>
<a class="btn btn-sm" href="/marketing?tab=live">Marketing Hub</a>
<a class="btn btn-sm" href="/marketing?tab=bookmarks">★ Bookmarks</a>
</div>
</header>
<div class="herman-rss-filters">
<input type="search" class="form-input" placeholder="Zoek in titels…" x-model="rssSearch" />
<select class="form-input" x-model="rssCategory" @change="loadRssTab()">
<option value="">Alle categorieën</option>
<option value="kant-en-klaar">Kant-en-klaar</option>
<option value="supermarkt">Supermarkt</option>
<option value="food">Food</option>
<option value="markt">Markt &amp; M&amp;A</option>
<option value="regelgeving">Regelgeving</option>
<option value="cbs">CBS</option>
</select>
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
</div>
<div class="herman-rss-grid">
<section class="herman-rss-col herman-rss-col-main">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
<div class="herman-rss-list">
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
<article class="feed-card herman-rss-card">
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
<div class="feed-actions">
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
</div>
</article>
</template>
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
</div>
</section>
<aside class="herman-rss-col herman-rss-col-side">
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
<h4>Regelgeving</h4>
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
<h4>CBS &amp; data</h4>
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="rssBookmarks.length">
<h4>★ Opgeslagen</h4>
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
<div class="herman-rss-side-item">
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
<small x-text="b.feed_name"></small>
</div>
</template>
</section>
</aside>
</div>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=14"></script>
<script>
localStorage.setItem('foodlinkk-persona', 'ceo');
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() {
return {
loading: false, loadingMsg: 'Herman werkt…', liveLog: [], liveProgress: '',
editLayout: false,
hermanTab: 'overview',
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
rssRegulations: {}, rssBookmarks: [],
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
liveDigest: INITIAL_BRIEFING.live_digest || null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
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, this.liveDigest);
},
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);
},
setHermanTab(tab) {
this.hermanTab = tab;
try {
const url = new URL(location.href);
if (tab === 'rss') url.searchParams.set('tab', 'rss');
else url.searchParams.delete('tab');
history.replaceState({}, '', url);
} catch (e) {}
if (tab === 'rss') this.loadRssTab();
},
filteredRss() {
const q = (this.rssSearch || '').toLowerCase().trim();
if (!q) return this.rssItems;
return this.rssItems.filter(function (r) {
return [r.title, r.feed_name, r.category, r.description].some(function (v) {
return v && String(v).toLowerCase().includes(q);
});
});
},
async loadRssTab() {
this.rssLoading = true;
try {
const params = new URLSearchParams({ limit: '100' });
if (this.rssCategory) params.set('category', this.rssCategory);
const [live, reg, bookmarks] = await Promise.all([
fetch('/api/retail/rss/live?' + params).then(function (x) { return x.json(); }),
fetch('/api/retail/regulations?limit=40').then(function (x) { return x.json(); }).catch(function () { return {}; }),
fetch('/api/retail/rss/bookmarks?limit=50').then(function (x) { return x.json(); }).catch(function () { return { items: [] }; }),
]);
this.rssItems = live.items || [];
this.rssTotal = live.count || this.rssItems.length;
this.rssRegulations = reg || {};
this.rssBookmarks = bookmarks.items || [];
if (this._stats) {
this._stats.rss_live = this.rssItems;
this._stats.rss_items = this.rssTotal;
}
} catch (e) {
Cockpit.toast(e.message || 'RSS laden mislukt', 'error');
}
this.rssLoading = false;
},
async init() {
const qsTab = new URLSearchParams(location.search).get('tab');
if (qsTab === 'rss') this.hermanTab = 'rss';
window._dashboardVizMode = this._vizMode;
const prefs = await DashboardLayout.loadPrefs();
this._prefs = prefs;
this._vizMode = prefs.global_viz_mode || 'neo-bars';
window._dashboardVizMode = this._vizMode;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
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) => {
this._vizMode = mode;
window._dashboardVizMode = mode;
await DashboardLayout.saveViz(mode);
document.getElementById('viz-label-sentiment').textContent = mode;
document.getElementById('viz-label-agents').textContent = mode;
if (this._stats) BriefingCharts.renderLive(dash, this._stats, mode, window._dashboardKpis);
});
window._refreshRssDash = () => this.refreshRss(true);
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.loadProjectsMini();
if (this.liveDigest) {
BriefingCharts.renderLiveSummary(this.liveDigest, true);
} else if (this.content) {
BriefingCharts.renderText(dash, this.content, this.createdAt);
}
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
if (this.hermanTab === 'rss') await this.loadRssTab();
},
async refreshRss(toast) {
if (toast) Cockpit.toast('RSS feeds ophalen…', 'info');
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadRssTab();
if (this._stats) {
BriefingCharts.renderRetail(document.getElementById('briefing-retail'), this._stats);
BriefingCharts.renderFoodHighlights(document.getElementById('briefing-food-highlights'), this._stats);
BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, window._dashboardKpis);
}
if (toast) Cockpit.toast('RSS bijgewerkt (' + (this.rssTotal || 0) + ' items)', 'success');
} catch (e) { if (toast) Cockpit.toast(e.message || 'RSS fout', 'error'); }
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('dash-approvals-list');
const badge = document.getElementById('approvals-pending-badge');
if (!el) return;
const items = r.items || [];
if (badge) {
badge.style.display = items.length ? 'inline' : 'none';
badge.textContent = items.length + ' open';
}
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr class="'+(i.action_type==='maintenance_scan'?'approval-row-scan':'')+'"><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓ Goedkeuren</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table><p class="page-tip" style="margin-top:0.5rem"><a href="/agents?tab=approvals">Volledige queue →</a></p>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
},
async loadProjectsMini() {
try {
const r = await fetch('/api/projects?limit=5').then(x => x.json());
const el = document.getElementById('dash-projects-mini');
if (!el) return;
const items = r.items || [];
el.innerHTML = items.length ? items.map(p =>
'<a href="/projects?id='+p.id+'" class="beurs-mini-chip"><strong>'+p.name+'</strong> · '+(p.asset_count||0)+' assets</a>'
).join('') : '<p class="muted">Nog geen projecten — <a href="/projects">maak aan</a></p>';
} catch (e) {}
},
renderActivityLog(entries) {
const el = document.getElementById('briefing-activity-log');
if (!el) return;
el.innerHTML = (entries || []).length
? entries.slice(0, 12).map(e => '<li>' + e + '</li>').join('')
: '<li class="muted">Nog geen agent-acties vandaag — Herman documenteert automatisch.</li>';
},
renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini');
if (!el) return;
const active = stats.clients_active || 0, total = stats.clients_total || stats.clients || 0;
el.innerHTML = '<a href="/clients" class="beurs-mini-chip up"><strong>' + active + '</strong> actief</a>' +
'<a href="/clients" class="beurs-mini-chip"><strong>' + total + '</strong> totaal klanten</a>' +
'<a href="/retail" class="beurs-mini-chip"><strong>' + (stats.crm_partnerships||0) + '</strong> partnerships</a>' +
'<a href="/marketing?tab=reclame" class="beurs-mini-chip"><strong>' + (stats.promo_campaigns||0) + '</strong> promo\'s</a>';
},
async refreshLive(toast) {
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) {
this._stats = r.stats;
if (r.live_digest) this.liveDigest = r.live_digest;
try {
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
if (live.items && live.items.length) {
this._stats.rss_live = live.items;
this._stats.rss_items = live.count || live.items.length;
this.rssTotal = this._stats.rss_items;
}
} catch (e) {}
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
this.renderClientsMini(this._stats);
this.renderActivityLog(this._stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
},
pushLiveEntry(entry) {
this.liveLog.push(entry);
this.loadingMsg = entry.message || this.loadingMsg;
if (entry.type === 'step') {
const done = this.liveLog.filter(e => e.type === 'step' && e.status === 'ok').length;
this.liveProgress = done + ' bronnen geladen';
}
this.$nextTick(() => {
const log = document.getElementById('hm-live-log');
if (log) log.scrollTop = log.scrollHeight;
const bar = document.getElementById('hm-live-bar');
if (bar && this.loading) bar.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
},
async generate() {
this.loading = true;
this.liveLog = [];
this.liveProgress = '';
this.loadingMsg = 'Herman start dagrapport…';
window.scrollTo({ top: 0, behavior: 'smooth' });
const bar = document.getElementById('hm-live-bar');
if (bar) bar.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const resp = await fetch('/api/herman/briefing/stream', { method: 'POST' });
if (!resp.ok) throw new Error('Stream mislukt (' + resp.status + ')');
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let result = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const chunk of parts) {
const line = chunk.trim();
if (!line.startsWith('data:')) continue;
const evt = JSON.parse(line.replace(/^data:\s*/, ''));
if (evt.type === 'step' || evt.type === 'agent_msg') {
this.pushLiveEntry(evt);
} else if (evt.type === 'done') {
result = evt;
this.pushLiveEntry({ type: 'step', status: 'ok', source: 'Herman', message: '✓ Dagrapport klaar!' });
} else if (evt.type === 'error') {
throw new Error(evt.message || 'Onbekende fout');
}
}
}
if (!result) throw new Error('Geen resultaat van Herman');
this.content = result.content || '';
this.createdAt = result.generated_at || new Date().toISOString();
this._stats = result.stats;
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
this.renderClientsMini(result.stats || {});
this.renderActivityLog((result.stats || {}).activity_log || []);
document.getElementById('briefing-meta').textContent = 'Rapport: ' + this.createdAt.substring(0, 19).replace('T', ' ') + ' UTC';
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) {
this.pushLiveEntry({ type: 'step', status: 'warn', source: 'Fout', message: e.message || String(e) });
Cockpit.toast(e.message || 'Fout bij genereren', 'error');
}
this.loading = false;
this.loadingMsg = 'Klaar';
}
};
}
</script>
{% endblock %}
+227
View File
@@ -0,0 +1,227 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _safe_count(table: str, where: str = "") -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "") -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def _briefing_payload(briefing: dict | None) -> dict:
"""Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
live_stats = serialize_stats(collect_briefing_data())
payload: dict = {
"content": None,
"stats": live_stats,
"live_digest": build_live_digest(live_stats),
"created_at": None,
}
if not briefing:
return payload
payload["content"] = briefing.get("content")
payload["created_at"] = briefing.get("created_at")
return payload
@router.get("/")
async def dashboard(request: Request):
kpis = {
"deals_count": _safe_count("deals"),
"clients_count": _safe_count("clients"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
"browser_sessions_24h": 0,
"monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
"supermarkets_count": _safe_count("supermarkets"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
}
briefing = None
try:
briefing = fetch_one(
"SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
if briefing and briefing.get("created_at"):
briefing["created_at"] = briefing["created_at"].isoformat()
except Exception:
briefing = None
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
approvals: list = []
try:
approvals = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at ASC LIMIT 25
"""
)
for ev in approvals:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
approvals = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at,
LEFT(content_text, 300) AS preview
FROM browser_sessions
ORDER BY created_at DESC LIMIT 8
"""
)
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
monitor_sites: list = []
monitor_changes: list = []
try:
monitor_sites = fetch_all(
"""
SELECT id, name, url, last_title, last_crawled, is_active
FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
"""
)
for s in monitor_sites:
if s.get("last_crawled"):
s["last_crawled"] = s["last_crawled"].isoformat()
monitor_changes = fetch_all(
"""
SELECT pc.id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC LIMIT 10
"""
)
for c in monitor_changes:
if c.get("changed_at"):
c["changed_at"] = c["changed_at"].isoformat()
except Exception:
pass
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"page_title": "CEO Dashboard",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
"agent_feed": agent_feed,
"approvals": approvals,
"browser_sessions": browser_sessions,
"monitor_sites": monitor_sites,
"monitor_changes": monitor_changes,
},
)
@router.get("/cto")
async def cto_redirect():
return RedirectResponse(url="/ops", status_code=302)
@router.get("/cto/dashboard")
async def cto_dashboard_legacy(request: Request):
kpis = {
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"browser_sessions_24h": 0,
}
try:
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
except Exception:
pass
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at
FROM browser_sessions ORDER BY created_at DESC LIMIT 8
"""
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
return templates.TemplateResponse(
"cto-dashboard.html",
{
"request": request,
"page_title": "CTO Dashboard",
"kpis": kpis,
"agent_feed": agent_feed,
"browser_sessions": browser_sessions,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+66
View File
@@ -0,0 +1,66 @@
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
(function () {
var DEFAULT = 'overview';
function radioId(tab) {
return 'doctab-' + tab;
}
function getActiveTab() {
var checked = document.querySelector('.doc-tab-input:checked');
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
}
function show(tab) {
if (!tab) tab = DEFAULT;
var radio = document.getElementById(radioId(tab));
if (radio) radio.checked = true;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
window.__docActiveTab = tab;
}
function syncAlpine(tab) {
var hub = document.querySelector('.doc-hub');
if (!hub || !window.Alpine) return;
try {
var d = Alpine.$data(hub);
if (!d) return;
if (d.activeTab !== tab) d.activeTab = tab;
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
} catch (e) {
console.warn('doc-tabs alpine', e);
}
}
function onTabChange() {
var tab = getActiveTab();
window.__docActiveTab = tab;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
syncAlpine(tab);
}
function bind() {
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
if (radio.dataset.bound) return;
radio.dataset.bound = '1';
radio.addEventListener('change', onTabChange);
});
}
window.DocTabs = { show: show, getActive: getActiveTab };
bind();
document.addEventListener('DOMContentLoaded', function () {
bind();
show(window.__docActiveTab || getActiveTab());
});
document.addEventListener('alpine:initialized', function () {
bind();
show(window.__docActiveTab || getActiveTab());
syncAlpine(getActiveTab());
});
})();
+893
View File
@@ -0,0 +1,893 @@
/* Documents & NAS Workbench v2 */
[x-cloak] { display: none !important; }
.doc-hub {
--doc-accent: #00e5ff;
--doc-gold: #ffd700;
--doc-purple: #a855f7;
--doc-border: rgba(0, 229, 255, 0.14);
--doc-text: #f1f5f9;
--doc-muted: #94a3b8;
--doc-dim: #64748b;
}
.doc-hub .page-header h1 {
font-size: 1.65rem;
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Tabs */
.doc-nav {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.85);
border: 1px solid var(--doc-border);
border-radius: 14px;
}
.doc-nav-btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--doc-muted);
transition: all 0.18s;
user-select: none;
}
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
.doc-nav-btn.active,
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"],
#doctab-analytics360:checked ~ #doc-nav label[for="doctab-analytics360"] {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
}
/* Workbench shell */
.doc-workbench {
border-radius: 16px;
border: 1px solid var(--doc-border);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
}
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
.doc-tab-input {
position: fixed;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
#doc-tab-stage {
min-height: 50vh;
}
#doc-tab-stage [data-tab-panel] {
display: none !important;
}
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
#doctab-analytics360:checked ~ #doc-tab-stage [data-tab-panel="analytics360"],
#doc-tab-stage [data-tab-panel].tab-active {
display: block !important;
min-height: 50vh;
}
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
font-size: 0.65rem;
color: var(--doc-dim);
white-space: nowrap;
}
.dl-version-bar {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
}
.doc-row-fallback { opacity: 0.85; }
.share-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
margin-bottom: 0.5rem;
font-size: 0.68rem;
}
.share-crumb {
background: transparent;
border: none;
color: #7dd3fc;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 4px;
}
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
.share-crumb-sep { color: #475569; }
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
.share-browser-list { max-height: 52vh; overflow-y: auto; }
.doc-wb-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
}
.doc-wb-head h2 {
margin: 0;
font-size: 1.05rem;
color: var(--doc-text);
}
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
.doc-kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
.doc-kpi {
padding: 0.9rem 1.1rem;
border-right: 1px solid rgba(148, 163, 184, 0.06);
cursor: pointer;
transition: background 0.15s;
}
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
.doc-kpi:last-child { border-right: none; }
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
.doc-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
/* Split layout */
.doc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
min-height: 280px;
}
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
.doc-split-col {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-split-col:last-child { border-right: none; }
.doc-chart-wrap { height: 140px; position: relative; }
/* Document table */
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
.doc-table-wrap .data-table { font-size: 0.78rem; }
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
/* Label chips */
.doc-chip {
display: inline-block;
padding: 0.18rem 0.55rem;
margin: 0.12rem 0.12rem 0 0;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
background: rgba(0, 229, 255, 0.12);
color: #7dd3fc;
border: 1px solid rgba(0, 229, 255, 0.25);
white-space: nowrap;
}
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
/* Photo grid */
.photo-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.photo-tile {
border: 1px solid var(--doc-border);
border-radius: 14px;
overflow: hidden;
background: rgba(10, 15, 26, 0.95);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.photo-tile:hover {
transform: translateY(-2px);
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
}
.photo-tile img {
width: 100%;
height: 150px;
object-fit: cover;
background: #060a10;
display: block;
}
.photo-tile-body {
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.photo-tile-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
}
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
.photo-tile-labels {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-height: 1.5rem;
align-items: flex-start;
}
.photo-tile-ocr {
font-size: 0.68rem;
line-height: 1.4;
color: var(--doc-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.8em;
}
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
/* Label filter chips row */
.label-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0 1rem 0.75rem;
}
.label-filter-chip {
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.35);
background: transparent;
color: var(--doc-muted);
}
.label-filter-chip.active, .label-filter-chip:hover {
border-color: rgba(0, 229, 255, 0.4);
color: #e0f2fe;
background: rgba(0, 229, 255, 0.1);
}
/* ── Label modal (fixed overlap + dark text) ── */
.label-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.label-modal-backdrop.open {
display: flex;
}
.label-modal {
width: min(780px, 100%);
max-height: 92vh;
overflow-y: auto;
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 20px;
padding: 1.35rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
color: var(--doc-text);
}
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
.label-modal-preview {
display: grid;
grid-template-columns: 140px 1fr;
gap: 1rem;
margin: 1rem 0;
padding: 0.85rem;
background: rgba(0, 0, 0, 0.35);
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
}
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
.label-modal-preview img {
width: 100%;
border-radius: 10px;
object-fit: cover;
max-height: 160px;
border: 1px solid rgba(148, 163, 184, 0.15);
}
.modal-ocr-box {
font-size: 0.74rem;
line-height: 1.55;
color: #cbd5e1 !important;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 8px;
padding: 0.65rem 0.75rem;
max-height: 140px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.modal-ocr-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
margin-bottom: 0.35rem;
}
.label-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 0.5rem;
}
.label-pick {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
min-height: 92px;
border-radius: 12px;
border: 1px solid rgba(100, 116, 139, 0.35);
background: rgba(15, 23, 42, 0.7);
cursor: pointer;
transition: all 0.15s;
color: #e2e8f0;
}
.label-pick:hover {
border-color: rgba(0, 229, 255, 0.4);
background: rgba(0, 229, 255, 0.06);
}
.label-pick.selected {
border-color: var(--pick-color, #00e5ff);
background: rgba(0, 229, 255, 0.14);
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
}
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
.label-pick-name {
font-size: 0.68rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
color: #cbd5e1;
display: block;
width: 100%;
word-break: break-word;
}
.label-pick.selected .label-pick-name { color: #f8fafc; }
.label-modal-extra {
margin-top: 1rem;
display: grid;
gap: 0.5rem;
}
.label-modal-extra label {
font-size: 0.72rem;
color: var(--doc-muted);
display: block;
margin-bottom: 0.25rem;
}
.label-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
/* Document label drawer */
.doc-label-drawer {
margin: 1rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid rgba(0, 229, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
}
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
/* ── Docling workbench ── */
.dl-workbench { min-height: 620px; }
.dl-body {
display: grid;
grid-template-columns: 260px 300px 1fr;
min-height: 560px;
}
/* v3 split workbench: files | editor+preview */
.dl-body-v3 {
display: grid;
grid-template-columns: minmax(260px, 300px) 1fr;
min-height: 580px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
.dl-pipeline-panel {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.18);
}
.dl-pipeline-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
.dl-workspace {
display: flex;
flex-direction: column;
min-height: 580px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.dl-workspace.empty { justify-content: center; align-items: center; }
.dl-workspace-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.dl-ws-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
flex-wrap: wrap;
}
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
.dl-workspace-panes {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
gap: 0;
}
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
.dl-pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.06);
}
.dl-pane:last-child { border-right: none; }
.dl-pane-label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0.85rem;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
gap: 0.5rem;
flex-wrap: wrap;
}
.dl-export-tabs-inline { margin: 0; }
.dl-editor-full {
flex: 1;
min-height: 420px;
max-height: none;
border: none;
border-radius: 0;
resize: none;
}
.dl-preview-viewport {
flex: 1;
min-height: 420px;
overflow: auto;
padding: 0.65rem;
}
.dl-preview-viewport .dl-preview-box {
min-height: 200px;
max-height: none;
height: 100%;
}
.dl-preview-img {
max-width: 100%;
max-height: 480px;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.dl-preview-iframe {
width: 100%;
height: 480px;
border: none;
border-radius: 8px;
background: #fff;
}
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
.dl-file-meta { flex: 1; min-width: 0; }
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
.dl-body-v3 .dl-sidebar {
max-height: none;
height: 100%;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 1200px) {
.dl-body-v3 { grid-template-columns: 1fr; }
.dl-body-v3 .dl-sidebar { max-height: 280px; }
}
@media (max-width: 1200px) {
.dl-body { grid-template-columns: 240px 1fr; }
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
}
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
overflow-y: auto;
max-height: 680px;
}
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
.dl-options { background: rgba(0, 0, 0, 0.12); }
.dl-section-title {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--doc-dim);
margin: 0 0 0.5rem;
}
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-ext-chip {
padding: 0.22rem 0.5rem;
border-radius: 6px;
font-size: 0.65rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
.dl-file-item {
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.12s;
}
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
.dl-file-item.selected {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.1);
}
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-badge {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.58rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
.dl-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(100, 116, 139, 0.2);
font-size: 0.76rem;
color: var(--doc-muted);
cursor: pointer;
transition: all 0.12s;
}
.dl-toggle:has(input:checked) {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
color: #e0f2fe;
}
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
.dl-preview-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-tab {
padding: 0.32rem 0.7rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-preview-box {
min-height: 320px;
max-height: 480px;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.1);
border-radius: 10px;
padding: 0.85rem;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
}
.dl-preview-box.rendered { white-space: normal; }
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
.dl-stat {
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(148, 163, 184, 0.1);
}
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
.dl-history-item {
padding: 0.45rem 0.55rem;
border-radius: 8px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--doc-muted);
}
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
.dl-progress {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
overflow: hidden;
margin-top: 0.5rem;
}
.dl-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00e5ff, #a855f7);
animation: dl-pulse 1.2s ease-in-out infinite;
width: 40%;
}
.dl-editor {
width: 100%;
min-height: 360px;
max-height: 520px;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.76rem;
line-height: 1.55;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.2);
border-radius: 10px;
padding: 0.85rem;
color: #e2e8f0;
resize: vertical;
}
.ppt-workbench { min-height: 560px; }
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
.ppt-editor { padding: 1rem; }
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
/* ── Document chat (RAG) ── */
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
.doc-chat-input .form-input { flex: 1; }
/* ── 360 Analytics ── */
.analytics360-workbench { min-height: 560px; }
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
.a360-links { max-height: 200px; overflow-y: auto; }
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); }
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(0,229,255,0.1); color: #7dd3fc; }
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
@keyframes dl-pulse {
0%, 100% { transform: translateX(-100%); width: 30%; }
50% { transform: translateX(200%); width: 50%; }
}
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
{% extends "base.html" %}
{% block title %}Documents · NAS Intelligence · Foodlinkk{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/documents-v2.css?v=19" />
<script>
window.__DOC_DATA = {
topWords: {{ top_words | tojson }},
summary: {{ summary | tojson }},
documents: {{ documents | tojson }},
};
</script>
<script src="/static/js/documents-v2.js?v=19"></script>
{% endblock %}
{% block content %}
<div class="doc-hub" x-data="documentsDash()" x-init="init()">
<input type="radio" name="doctab" id="doctab-overview" class="doc-tab-input" value="overview" checked aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-labels" class="doc-tab-input" value="labels" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-docling" class="doc-tab-input" value="docling" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-powerpoint" class="doc-tab-input" value="powerpoint" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-chat" class="doc-tab-input" value="chat" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-analytics360" class="doc-tab-input" value="analytics360" aria-hidden="true" tabindex="-1" />
<header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>NAS Document Intelligence</h1>
<p class="hero-sub">Corpus · sentiment · labeling · Docling conversie</p>
<p class="page-tip" id="doc-hub-status" style="margin-top:0.5rem;color:#fbbf24;display:none"></p>
</header>
<nav class="doc-nav" id="doc-nav" aria-label="Document secties">
<label for="doctab-overview" class="doc-nav-btn">Overzicht</label>
<label for="doctab-labels" class="doc-nav-btn">Labels &amp; foto's</label>
<label for="doctab-docling" class="doc-nav-btn">Workbench</label>
<label for="doctab-powerpoint" class="doc-nav-btn">PowerPoint</label>
<label for="doctab-chat" class="doc-nav-btn">Chat</label>
<label for="doctab-analytics360" class="doc-nav-btn">360° Klant</label>
</nav>
<div id="doc-tab-stage" class="doc-tab-stage">
<!-- ═══ OVERVIEW ═══ -->
<div data-tab-panel="overview" class="doc-workbench doc-panel">
<div class="doc-kpi-strip">
<div class="doc-kpi" @click="filterSentiment=''; applyFilters()">
<div class="lbl">Documenten</div>
<div class="val" x-text="filteredDocs.length"></div>
</div>
<div class="doc-kpi">
<div class="lbl">Totaal woorden</div>
<div class="val">{{ summary.total_words }}</div>
</div>
<div class="doc-kpi" @click="switchTab('labels')">
<div class="lbl">Foto's OCR</div>
<div class="val" x-text="photos.length"></div>
</div>
<div class="doc-kpi">
<div class="lbl">Gem. sentiment</div>
<div class="val">{{ summary.avg_sentiment }}</div>
</div>
</div>
<div class="doc-toolbar">
<input type="search" class="form-input" placeholder="Zoek bestand of label…" x-model="filterQuery" @input="applyFilters()" />
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
<option value="">Alle sentiment</option>
<option value="positive">Positief</option>
<option value="neutral">Neutraal</option>
<option value="negative">Negatief</option>
</select>
<select class="form-input" x-model="filterType" @change="applyFilters()">
<option value="">Alle types</option>
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
</select>
<select class="form-input" x-model="filterLabel" @change="applyFilters()">
<option value="">Alle labels</option>
<template x-for="lb in labelVocabulary" :key="lb.label">
<option :value="lb.label" x-text="lb.label + ' (' + lb.usage_count + ')'"></option>
</template>
</select>
<label style="font-size:0.78rem;color:#64748b;display:flex;align-items:center;gap:0.35rem">
<input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live
</label>
<button class="btn btn-secondary btn-sm" @click="openSelectedInWorkbench()" :disabled="!selectedSharePaths.length">Open in Workbench (<span x-text="selectedSharePaths.length"></span>)</button>
<button class="btn btn-secondary btn-sm" @click="refreshShare()">Share scan</button>
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">
<span x-text="nasOcrBusy ? 'OCR…' : 'NAS OCR'"></span>
</button>
</div>
<div class="doc-split">
<div class="doc-split-col">
<div class="doc-section-title" style="font-size:0.65rem;text-transform:uppercase;letter-spacing:0.1em;color:#64748b;margin-bottom:0.5rem">Analytics</div>
<div class="doc-chart-wrap"><canvas id="wordsChart"></canvas></div>
<div class="doc-chart-wrap" style="margin-top:0.75rem"><canvas id="sentimentChart"></canvas></div>
</div>
<div class="doc-split-col doc-table-wrap">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<strong style="color:#f1f5f9;font-size:0.85rem">NAS documenten</strong>
<span style="font-size:0.72rem;color:#64748b" x-text="filteredDocs.length + ' resultaten'"></span>
</div>
<table class="data-table">
<thead>
<tr><th style="width:2rem"></th><th>Bestand</th><th>Gewijzigd</th><th>Labels</th><th>Sentiment</th><th>Woorden</th><th></th></tr>
</thead>
<tbody id="doc-table-body">
{% for doc in documents[:80] %}
<tr class="clickable-row doc-row-fallback">
<td></td>
<td><strong>{{ doc.filename }}</strong><span class="path-cell">{{ doc.storage_path }}</span></td>
<td class="doc-ts-cell">{{ doc.modified_at or doc.analyzed_at or '—' }}</td>
<td>{% if doc.user_labels %}{% for lb in doc.user_labels %}<span class="doc-chip">{{ lb }}</span>{% endfor %}{% else %}<span class="doc-chip muted">—</span>{% endif %}</td>
<td><span class="badge badge-{{ doc.sentiment_label or 'neutral' }}">{{ doc.sentiment_label or '—' }}</span></td>
<td>{{ doc.word_count or 0 }}</td>
<td></td>
</tr>
{% endfor %}
<template x-for="doc in filteredDocs" :key="doc.storage_path">
<tr class="clickable-row" :class="activeDoc && activeDoc.storage_path === doc.storage_path && 'doc-row-selected'">
<td @click.stop>
<input type="checkbox" :checked="isShareSelected(doc.storage_path)" @change="toggleShareSelect(doc.storage_path)" />
</td>
<td @click="openDocLabel(doc)">
<strong x-text="doc.filename"></strong>
<span class="path-cell" x-text="doc.storage_path"></span>
</td>
<td class="doc-ts-cell" x-text="formatTs(doc.modified_at || doc.analyzed_at)"></td>
<td @click="openDocLabel(doc)">
<template x-for="lb in (doc.user_labels || [])" :key="lb">
<span class="doc-chip" x-text="lb"></span>
</template>
<span class="doc-chip muted" x-show="!(doc.user_labels || []).length">—</span>
</td>
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
<td x-text="doc.word_count"></td>
<td @click.stop>
<button type="button" class="btn btn-sm btn-secondary" @click="openInWorkbench(doc.storage_path)">Bewerk</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div class="doc-label-drawer" x-show="activeDoc">
<h3>Label document — <span x-text="activeDoc && activeDoc.filename"></span></h3>
<div class="label-modal-grid">
<template x-for="t in documentTaxonomy" :key="t.id">
<button type="button" class="label-pick" :class="docSelectedLabels.includes(t.id) && 'selected'"
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleDocLabel(t.id)">
<span class="label-pick-emoji" x-text="t.emoji"></span>
<span class="label-pick-name" x-text="t.label"></span>
</button>
</template>
</div>
<div class="label-modal-extra">
<div><label>Extra labels</label><input type="text" class="form-input" x-model="docLabelsInput" placeholder="komma-gescheiden" /></div>
<div><label>Notities</label><textarea class="form-input" rows="2" x-model="docNotesInput"></textarea></div>
</div>
<div class="label-modal-actions" style="border:none;padding-top:0.75rem">
<button class="btn btn-secondary btn-sm" @click="activeDoc=null">Sluiten</button>
<button class="btn btn-primary btn-sm" @click="saveDocLabels()">Opslaan</button>
<button class="btn btn-secondary btn-sm" @click="doclingSelected=activeDoc.storage_path; switchTab('docling')">→ Docling</button>
</div>
</div>
</div>
<!-- ═══ LABELS & PHOTOS ═══ -->
<div data-tab-panel="labels" class="doc-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Foto labeling studio</h2>
<p>Klik een foto → kies label-type → opslaan</p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:200px" />
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">Vernieuwen</button>
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">NAS OCR</button>
</div>
</div>
<div class="label-filter-row">
<button type="button" class="label-filter-chip" :class="!photoFilterLabel && 'active'" @click="photoFilterLabel=''">Alle</button>
<template x-for="t in photoTaxonomy" :key="'f'+t.id">
<button type="button" class="label-filter-chip" :class="photoFilterLabel===t.id && 'active'"
@click="photoFilterLabel=t.id" x-text="t.emoji + ' ' + t.label"></button>
</template>
</div>
<div class="photo-masonry">
<template x-for="p in filteredPhotos()" :key="p.id">
<article class="photo-tile" @click="openPhotoModal(p.id)">
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
<div class="photo-tile-body">
<div class="photo-tile-head">
<span class="photo-tile-id" x-text="'#' + p.id"></span>
<span class="photo-tile-src" x-text="p.source || 'upload'"></span>
</div>
<div class="photo-tile-labels">
<template x-for="lb in (p.user_labels || [])" :key="lb">
<span class="doc-chip" x-text="labelDisplayName(lb)"></span>
</template>
<span class="doc-chip muted" x-show="!(p.user_labels || []).length">Nog geen label</span>
</div>
<p class="photo-tile-ocr" x-text="p.ocr_preview || 'Geen OCR tekst'"></p>
<span class="photo-tile-meta" x-text="(p.box_count||0) + ' detecties · ' + (p.item_count||0) + ' items'"></span>
</div>
</article>
</template>
<p class="empty-state" x-show="!filteredPhotos().length" style="grid-column:1/-1;padding:2rem">Geen foto's — upload, Telegram of NAS OCR.</p>
</div>
</div>
<!-- ═══ DOCLING STUDIO ═══ -->
<div data-tab-panel="docling" class="doc-workbench dl-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Document Workbench</h2>
<p x-text="doclingStatus"></p>
<div class="dl-progress" x-show="doclingBusy"><div class="dl-progress-bar"></div></div>
</div>
<div class="dl-head-actions">
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="doclingAutoConvert" /> Auto-convert bij klik</label>
<label class="dl-toggle"><input type="checkbox" x-model="shareBrowseRecursive" @change="browseShare(shareBrowsePath || '', false)" /> Incl. submappen</label>
<button class="btn btn-secondary btn-sm" :disabled="nasLoading || shareBrowseLoading" @click="loadAllNasFiles(true); browseShare(shareBrowsePath || '', true)"><span x-text="(nasLoading || shareBrowseLoading) ? 'Scannen…' : '↻ NAS'"></span></button>
<button class="btn btn-secondary btn-sm" @click="dlOptionsOpen=!dlOptionsOpen" x-text="dlOptionsOpen ? 'Pipeline ▲' : 'Pipeline ▼'"></button>
<button class="btn btn-primary btn-sm" :disabled="doclingBusy || !doclingSelected" @click="runDoclingConvert()">↻ Convert</button>
<button class="btn btn-secondary btn-sm" :disabled="doclingBusy" @click="runDoclingBatch()">Batch</button>
</div>
</div>
<div class="dl-pipeline-panel" x-show="dlOptionsOpen">
<div class="dl-pipeline-grid">
<div>
<p class="dl-section-title">Pipeline</p>
<div class="dl-toggle-grid">
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.ocr" /> OCR</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.tables" /> Tabellen</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.page_images" /> Pagina's</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.picture_images" /> Figuren</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.force_full_page_ocr" /> Full-page OCR</label>
</div>
</div>
<div>
<p class="dl-section-title">Export</p>
<div class="dl-format-grid">
<template x-for="fmt in doclingExportFormats" :key="fmt">
<label class="dl-toggle" style="padding:0.35rem 0.5rem">
<input type="checkbox" :value="fmt" x-model="doclingFormats" />
<span x-text="fmt"></span>
</label>
</template>
</div>
</div>
<div class="dl-stats-grid" x-show="doclingResult">
<div class="dl-stat"><div class="n" x-text="doclingMeta.page_count || 0"></div><div class="l">Pagina's</div></div>
<div class="dl-stat"><div class="n" x-text="doclingTables.length"></div><div class="l">Tabellen</div></div>
<div class="dl-stat"><div class="n" x-text="doclingPictures.length"></div><div class="l">Figuren</div></div>
<div class="dl-stat"><div class="n" x-text="doclingAvailableExports().length"></div><div class="l">Exports</div></div>
</div>
</div>
</div>
<div class="dl-body-v3">
<!-- Bestanden links -->
<aside class="dl-sidebar">
<p class="dl-section-title">Share <span x-text="'(' + (shareBrowseRecursive ? 'recursief · ' : '') + shareBrowsePath + ')'"></span></p>
<nav class="share-breadcrumb" aria-label="Share pad">
<button type="button" class="share-crumb" @click="browseShare('')">Share</button>
<template x-for="(part, idx) in sharePathParts()" :key="'bc'+idx">
<span class="share-crumb-sep">/</span>
<button type="button" class="share-crumb" @click="browseShare(sharePathPrefix(idx))" x-text="part"></button>
</template>
</nav>
<div class="dl-ext-chips">
<template x-for="g in dlExtGroups" :key="g.ext || 'all'">
<button type="button" class="dl-ext-chip" :class="doclingExtFilter===g.ext && 'active'" @click="doclingExtFilter=g.ext" x-text="(g.icon ? g.icon + ' ' : '') + g.label"></button>
</template>
</div>
<input type="search" class="form-input dl-search" placeholder="Zoek in map…" x-model="doclingFilter" />
<div class="dl-file-list share-browser-list">
<div class="dl-file-item share-folder-item" x-show="shareBrowsePath" @click="browseShareParent()">
<span class="dl-file-icon">📁</span>
<span class="dl-file-name">.. (omhoog)</span>
</div>
<template x-for="entry in shareBrowseFolders()" :key="'d'+entry.path">
<div class="dl-file-item share-folder-item" @click="browseShare(entry.path)">
<span class="dl-file-icon">📁</span>
<div class="dl-file-meta">
<span class="dl-file-name" x-text="entry.name"></span>
<span class="dl-file-path" x-text="entry.path"></span>
</div>
</div>
</template>
<template x-for="f in doclingFilteredFiles()" :key="f.path">
<div class="dl-file-item" :class="doclingSelected===f.path && 'selected'" @click.prevent="selectDoclingFile(f.path)">
<div class="dl-file-row">
<span class="dl-file-icon" x-text="fileIcon(f.path)"></span>
<div class="dl-file-meta">
<span class="dl-file-name" x-text="f.filename || f.path"></span>
<span class="dl-file-path" x-text="f.path"></span>
</div>
<span class="dl-file-ext" x-text="fileExtension(f.path)"></span>
</div>
<div class="dl-file-tags">
<span class="dl-file-ts" x-text="formatTs(f.modified_at)"></span>
<span class="dl-file-badge" x-show="fileDoclingStatus(f.path)==='done'">✓</span>
<span class="dl-file-badge pending" x-show="fileDoclingStatus(f.path)==='running'">…</span>
<span class="dl-file-badge failed" x-show="fileDoclingStatus(f.path)==='failed'">✕</span>
<span class="dl-file-badge convert" x-show="doclingSelected===f.path && doclingFileLoading">laden</span>
</div>
</div>
</template>
<p class="empty-state" x-show="!shareBrowseFolders().length && !doclingFilteredFiles().length" style="font-size:0.72rem">Geen items — klik ↻ NAS of controleer Synology rechten.</p>
</div>
</aside>
<!-- Workspace rechts: editor + preview -->
<div class="dl-workspace" :class="!doclingSelected && 'empty'">
<div class="dl-workspace-inner" x-show="doclingSelected" style="display:flex;flex-direction:column;flex:1">
<div class="dl-ws-head">
<div class="dl-ws-title">
<span class="dl-ws-icon" x-text="fileIcon(doclingSelected)"></span>
<div>
<strong x-text="doclingSelected.split('/').pop()"></strong>
<span class="dl-ws-path" x-text="doclingSelected"></span>
</div>
<span class="dl-ws-kind" x-text="fileKindLabel(doclingSelected)"></span>
<span class="dl-ws-ts" x-show="doclingFileMtime" x-text="'NAS: ' + formatTs(doclingFileMtime)"></span>
</div>
<div class="dl-version-bar" x-show="docVersions.length">
<label style="font-size:0.72rem;color:#94a3b8">Versie</label>
<select class="form-input" style="max-width:280px;font-size:0.72rem" @change="loadVersion($event.target.value)">
<option value="">Huidige bewerking</option>
<template x-for="v in docVersions" :key="v.id">
<option :value="v.id" x-text="formatTs(v.created_at) + (v.version_label ? ' · ' + v.version_label : '')"></option>
</template>
</select>
</div>
<div class="dl-preview-tools">
<button class="btn btn-sm btn-primary" :disabled="!doclingEditContent" @click="saveWorkspace()">Opslaan</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingEditContent" @click="saveToNas()">→ NAS</button>
<button class="btn btn-sm btn-secondary" @click="sendToPowerpoint()" x-show="doclingEditContent">→ PPT</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="copyDoclingExport()">Kopieer</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="downloadDoclingExport()">Download</button>
<a class="btn btn-sm btn-secondary" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" target="_blank" rel="noopener">Open origineel</a>
</div>
</div>
<div class="dl-loading-bar" x-show="doclingFileLoading || doclingBusy"><div class="dl-progress-bar"></div></div>
<div class="dl-workspace-panes">
<!-- Editor -->
<div class="dl-pane dl-pane-editor">
<div class="dl-pane-label">Editor <span x-text="doclingFileKind === 'text' ? '(raw)' : '(markdown)'"></span></div>
<textarea class="dl-editor dl-editor-full" x-model="doclingEditContent" :placeholder="doclingFileLoading ? 'Laden…' : 'Klik een bestand links — inhoud verschijnt hier…'" :disabled="doclingFileLoading"></textarea>
</div>
<!-- Preview -->
<div class="dl-pane dl-pane-preview">
<div class="dl-pane-label">
Preview
<div class="dl-export-tabs dl-export-tabs-inline" x-show="doclingResult">
<template x-for="fmt in doclingAvailableExports()" :key="'t'+fmt">
<button type="button" class="dl-tab" :class="doclingExportTab===fmt && 'active'" @click="doclingExportTab=fmt" x-text="fmt"></button>
</template>
<button type="button" class="dl-tab" :class="doclingExportTab==='rendered' && 'active'" @click="doclingExportTab='rendered'" x-show="doclingExports.markdown">md</button>
<button type="button" class="dl-tab" :class="doclingExportTab==='original' && 'active'" @click="doclingExportTab='original'">orig</button>
</div>
</div>
<div class="dl-preview-viewport">
<img x-show="doclingFileKind==='image'" class="dl-preview-img" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" :alt="doclingSelected" />
<iframe x-show="doclingFileKind==='pdf' && doclingExportTab==='original'" class="dl-preview-iframe" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'"></iframe>
<div x-show="doclingExportTab==='html' && doclingPreviewHtml()" class="dl-preview-box rendered" x-html="doclingPreviewHtml()"></div>
<div x-show="doclingExportTab==='rendered' && doclingExports.markdown" class="dl-preview-box rendered" x-html="doclingRenderedMarkdown()"></div>
<pre class="dl-preview-box" x-show="doclingResult && doclingExportTab!=='html' && doclingExportTab!=='rendered' && doclingExportTab!=='original' && doclingExportTab!=='tables' && doclingExportTab!=='pictures'" x-text="doclingPreviewText()"></pre>
<p class="empty-state" x-show="!doclingResult && !doclingFileLoading && doclingFileKind!=='image' && doclingFileKind!=='pdf'" style="padding:2rem">Converteert… of geen preview beschikbaar.</p>
</div>
</div>
</div>
</div>
</div>
<div class="dl-workspace-empty" x-show="!doclingSelected">
<p class="empty-state" style="padding:3rem 1rem;text-align:center">
<span style="font-size:2rem;display:block;margin-bottom:0.5rem">📂</span>
Klik een bestand links om direct te bewerken.<br/>
<span style="font-size:0.78rem;color:#64748b">PDF · Word · Excel · PowerPoint · tekst · afbeeldingen — auto-convert aan</span>
</p>
</div>
</div>
</div>
</div>
<!-- ═══ POWERPOINT STUDIO ═══ -->
<div data-tab-panel="powerpoint" class="doc-workbench ppt-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>PowerPoint Studio</h2>
<p>Maak presentaties van NAS PDF's of bewerkte Docling-markdown</p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" @click="loadAllNasFiles(false)">↻ Bestanden</button>
<button class="btn btn-primary btn-sm" :disabled="pptBusy || !pptSlides.length" @click="createPptx()">Genereer PPTX</button>
</div>
</div>
<div class="ppt-grid">
<aside class="ppt-sidebar">
<p class="dl-section-title">Bron PDF (NAS)</p>
<div class="dl-file-list">
<template x-for="f in pptPdfFiles()" :key="'pdf'+f.path">
<div class="dl-file-item" :class="pptSourcePdf===f.path && 'selected'" @click="pptSourcePdf=f.path">
<span class="dl-file-name" x-text="f.filename"></span>
<span class="dl-file-path" x-text="f.path"></span>
</div>
</template>
</div>
<button class="btn btn-sm btn-secondary" style="width:100%;margin-top:0.5rem" :disabled="!pptSourcePdf || pptBusy" @click="convertPdfToPptx()">PDF → PPTX (smart)</button>
<p class="dl-section-title" style="margin-top:1rem">Bestaande PPTX</p>
<div class="dl-file-list">
<template x-for="f in pptPptxFiles()" :key="'ppt'+f.path">
<div class="dl-file-item" :class="pptDownloadPath===f.path && 'selected'" @click.prevent="openInWorkbench(f.path)">
<span class="dl-file-name" x-text="f.filename"></span>
<span class="dl-file-path" x-text="f.path"></span>
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.25rem" @click.stop="openInWorkbench(f.path)">Open in Workbench</button>
</div>
</template>
</div>
</aside>
<div class="ppt-editor">
<div class="ppt-meta-row">
<input class="form-input" placeholder="Presentatie titel" x-model="pptTitle" />
<input class="form-input" placeholder="Subtitel" x-model="pptSubtitle" />
<input class="form-input" placeholder="Bestandsnaam (optioneel)" x-model="pptFilename" />
</div>
<div style="display:flex;gap:0.45rem;margin-bottom:0.75rem;flex-wrap:wrap">
<button class="btn btn-sm btn-secondary" @click="importMarkdownToSlides()">Importeer Docling markdown</button>
<button class="btn btn-sm btn-secondary" @click="addPptSlide()">+ Slide</button>
</div>
<div class="ppt-slides">
<template x-for="(slide, idx) in pptSlides" :key="'slide'+idx">
<div class="ppt-slide-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem">
<strong style="font-size:0.78rem;color:#94a3b8">Slide <span x-text="idx+1"></span></strong>
<button class="btn btn-sm btn-secondary" @click="removePptSlide(idx)">✕</button>
</div>
<input class="form-input" placeholder="Slide titel" x-model="slide.title" style="margin-bottom:0.35rem" />
<textarea class="form-input" rows="4" placeholder="Bullets (één per regel)" x-model="slide.bulletsText" @input="syncSlideBullets(slide)"></textarea>
</div>
</template>
<p class="empty-state" x-show="!pptSlides.length">Voeg slides toe of importeer uit Docling.</p>
</div>
<p class="page-tip" x-show="pptLastOutput" style="margin-top:0.75rem">Laatste export: <span x-text="pptLastOutput"></span></p>
</div>
</div>
</div>
<!-- ═══ DOCUMENT CHAT (RAG) ═══ -->
<div data-tab-panel="chat" class="doc-workbench doc-chat-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Chat met je data</h2>
<p>Herman + RAG over NAS-documenten · <span x-text="chatStatus"></span></p>
<p class="page-tip" x-show="nasDiagHint" x-text="nasDiagHint" style="margin-top:0.35rem;color:#fbbf24"></p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center">
<select class="form-input" style="min-width:180px;font-size:0.75rem" x-model.number="chatLlmProviderId" @change="onChatProviderChange()">
<option value="">Standaard LLM</option>
<template x-for="p in llmProviders" :key="'llm'+p.id">
<option :value="p.id" x-text="p.label + ' · ' + p.model + (p.is_default ? ' ★' : '')"></option>
</template>
</select>
<a class="btn btn-sm btn-secondary" href="/settings?tab=ai" target="_blank">+ LLM API</a>
<button class="btn btn-secondary btn-sm" @click="reindexNas()" :disabled="chatReindexing"><span x-text="chatReindexing ? 'Indexeren…' : '↻ Index NAS'"></span></button>
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatUseHerman" /> Herman orchestrator (langzamer)</label>
<button class="btn btn-secondary btn-sm" @click="runAutoSync()" :disabled="autoSyncBusy"><span x-text="autoSyncBusy ? 'Sync…' : '⟳ Second Brain'"></span></button>
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatScopeFile" /> Scope: bestand</label>
</div>
</div>
<div class="doc-chat-layout">
<div class="doc-chat-messages" id="doc-chat-log">
<template x-for="(m, i) in chatMessages" :key="'cm'+i">
<div class="doc-chat-msg" :class="m.role">
<span class="doc-chat-role" x-text="m.role === 'user' ? 'Jij' : (m.agent || 'Herman')"></span>
<p x-text="m.content"></p>
<div class="doc-chat-sources" x-show="m.sources && m.sources.length">
<span style="font-size:0.62rem;color:#64748b">Bronnen:</span>
<template x-for="s in m.sources" :key="s">
<a class="doc-chat-src" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(s)" target="_blank" x-text="s.split('/').pop()"></a>
</template>
</div>
</div>
</template>
<p class="empty-state" x-show="!chatMessages.length" style="padding:2rem;text-align:center">
Stel een vraag over je documenten.<br/>
<span style="font-size:0.78rem;color:#64748b">bv. "Wat staat er in de Cucina catalogue?" of "Samenvat halal certificaten"</span>
</p>
</div>
<aside class="doc-chat-side">
<p class="dl-section-title">Snel zoeken</p>
<input class="form-input dl-search" placeholder="Zoek in corpus…" x-model="chatSearchQ" @keydown.enter="runChatSearch()" />
<button class="btn btn-sm btn-secondary" style="width:100%;margin-bottom:0.75rem" @click="runChatSearch()">Zoek</button>
<div class="doc-chat-hits">
<template x-for="h in chatSearchHits" :key="h.id || h.path">
<div class="doc-chat-hit" @click="chatInput = 'Wat staat er in ' + (h.filename || h.path) + '?'">
<strong x-text="h.filename || h.path"></strong>
<p x-text="(h.text || '').slice(0, 120) + '…'"></p>
</div>
</template>
</div>
<p class="dl-section-title" style="margin-top:1rem">Scope</p>
<p class="page-tip" x-show="chatScopeClient && linkClientId" x-text="linkedClientName()"></p>
<p class="page-tip" x-show="chatScopeFile && doclingSelected" x-text="'Bestand: ' + doclingSelected"></p>
</aside>
</div>
<form class="doc-chat-input" @submit.prevent="sendDocChat()">
<input class="form-input" x-model="chatInput" placeholder="Vraag aan Herman over je documenten…" :disabled="chatBusy" autocomplete="off" />
<button class="btn btn-primary" type="submit" :disabled="chatBusy || !chatInput.trim()"><span x-text="chatBusy ? '…' : 'Stuur'"></span></button>
</form>
</div>
<!-- ═══ 360° KLANT ANALYTICS ═══ -->
<div data-tab-panel="analytics360" class="doc-workbench analytics360-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>360° Klant Intelligence</h2>
<p>Koppel NAS → klant/project · trends · second brain · <span x-text="autoSyncLabel"></span></p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
<button class="btn btn-primary btn-sm" @click="linkSelectedFile()" :disabled="!doclingSelected || !linkClientId">+ Koppel bestand</button>
<button class="btn btn-secondary btn-sm" @click="linkSelectedFolder()" :disabled="!doclingSelected || !linkClientId">+ Koppel map</button>
</div>
</div>
<div class="a360-toolbar">
<select class="form-input" x-model.number="linkClientId" @change="loadClient360()">
<option value="">— Klant —</option>
<template x-for="c in linkClients" :key="'lc'+c.id">
<option :value="c.id" x-text="c.name"></option>
</template>
</select>
<select class="form-input" x-model.number="linkProjectId">
<option value="">— Project (optioneel) —</option>
<template x-for="p in linkProjectsForClient()" :key="'lp'+p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<input class="form-input" placeholder="NAS pad (of selecteer in Workbench)" x-model="linkPathInput" />
</div>
<div class="a360-grid" x-show="linkClientId">
<div class="a360-kpis">
<div class="dl-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Gekoppelde paden</p>
<div class="a360-links">
<template x-for="l in client360.links || []" :key="'lnk'+l.id">
<div class="a360-link-row">
<span x-text="(l.is_folder ? '📁 ' : '📄 ') + l.storage_path"></span>
<button class="btn btn-sm btn-secondary" @click="unlinkDocument(l.id)">✕</button>
</div>
</template>
<p class="empty-state" x-show="!(client360.links||[]).length">Nog geen koppelingen — selecteer bestand in Workbench</p>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Sentiment &amp; trends</p>
<div class="a360-sentiment">
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
</template>
</div>
<div class="a360-trends">
<template x-for="t in client360.monthly_trends || []" :key="t.month">
<div class="a360-trend-row">
<span x-text="(t.month||'').slice(0,7)"></span>
<span x-text="t.docs + ' docs'"></span>
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
</div>
</template>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Top woorden (klant corpus)</p>
<div class="a360-words">
<template x-for="w in client360.top_words || []" :key="w.lemma">
<span class="a360-word" x-text="w.lemma + ' (' + w.total + ')'"></span>
</template>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Retail &amp; deals</p>
<template x-for="s in client360.stores || []" :key="'st'+s.id">
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
</template>
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
</template>
</div>
</div>
<p class="empty-state" x-show="!linkClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
</div>
</div><!-- /doc-tab-stage -->
<!-- ═══ PHOTO LABEL MODAL ═══ -->
<div class="label-modal-backdrop" :class="photoModalOpen && 'open'"
@click.self="closePhotoModal()" @keydown.escape.window="closePhotoModal()">
<div class="label-modal" @click.stop role="dialog" aria-labelledby="labelModalTitle">
<div class="label-modal-head" style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 id="labelModalTitle">Wat voor label is deze foto?</h3>
<p class="modal-sub">Foto #<span x-text="activePhoto"></span> — selecteer één of meer types</p>
</div>
<button type="button" class="btn btn-sm btn-secondary" @click="closePhotoModal()" aria-label="Sluiten">✕</button>
</div>
<div class="label-modal-preview" x-show="activePhoto">
<img :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" alt="Preview" />
<div>
<div class="modal-ocr-label">OCR tekst</div>
<pre class="modal-ocr-box" x-text="activeOcr || 'Geen tekst gedetecteerd'"></pre>
</div>
</div>
<div class="label-modal-grid">
<template x-for="t in photoTaxonomy" :key="t.id">
<button type="button" class="label-pick" :class="modalPhotoLabels.includes(t.id) && 'selected'"
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleModalLabel(t.id)">
<span class="label-pick-emoji" x-text="t.emoji"></span>
<span class="label-pick-name" x-text="t.label"></span>
</button>
</template>
</div>
<div class="label-modal-extra">
<div>
<label>Extra label (optioneel)</label>
<input type="text" class="form-input" x-model="photoLabelsInput" placeholder="bv. Q2-2026, Jumbo" />
</div>
<div>
<label>Notities</label>
<textarea class="form-input" rows="2" x-model="photoNotesInput" placeholder="Toelichting…"></textarea>
</div>
</div>
<div class="label-modal-actions">
<button type="button" class="btn btn-secondary" @click="closePhotoModal()">Annuleren</button>
<button type="button" class="btn btn-primary" @click="saveModalPhotoLabels()">Label opslaan</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/doc-tabs.js?v=19"></script>
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overview",
"cto_home": "CTO Overview",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Clients",
"deals": "Deals",
"products": "Products",
"suppliers": "Suppliers",
"intel": "Intel",
"beurs": "Market Live",
"retail": "Retail",
"browser": "Browser",
"documents": "Documents",
"projects": "Projects",
"hermes": "Hermes",
"telegram": "Telegram",
"agents": "Agents",
"marketing": "Marketing",
"chat": "Chat",
"studio": "Studio",
"packaging": "Packaging",
"reports": "Reports",
"system": "System",
"analytics": "Analytics",
"voice": "Voice",
"ops": "IT Ops",
"settings": "Settings",
"gitea": "Gitea"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"refresh": "Refresh",
"loading": "Loading…",
"none": "—",
"yes": "Yes",
"no": "No",
"confirm_delete": "Are you sure you want to delete this?",
"saved": "Saved",
"deleted": "Deleted",
"error": "Error",
"all": "All",
"actions": "Actions",
"language": "Language",
"dutch": "Nederlands",
"english": "English"
},
"clients": {
"title": "CRM · Clients",
"subtitle": "Manage clients · pipeline · link stores · enrich branch data",
"new": "New client",
"tab_kanban": "Kanban",
"tab_list": "List",
"tab_stores": "Supermarkets",
"tab_360": "360° Client"
},
"products": {
"title": "Product catalog",
"subtitle": "SKUs · margins · client links · launch planning",
"new": "New product",
"tab_grid": "Overview",
"tab_list": "List",
"tab_margin": "Margin calculator",
"total": "Total products",
"active": "Active",
"avg_margin": "Avg. margin",
"with_client": "With client",
"name": "Product name",
"status": "Status",
"margin_pct": "Margin %",
"moq": "MOQ",
"shelf_target": "Shelf / listing",
"launch_date": "Launch date",
"client": "Client",
"status_concept": "Concept",
"status_development": "Development",
"status_active": "Active",
"status_discontinued": "Discontinued",
"calc_cost": "Cost price",
"calc_sell": "Sell price",
"calc_profit": "Profit",
"calc_margin": "Margin",
"calc_run": "Calculate",
"no_products": "No products yet — add your first SKU.",
"drawer_deals": "Linked client"
},
"suppliers": {
"title": "Suppliers",
"subtitle": "Vendor registry · ratings · lead times · contacts",
"new": "New supplier",
"tab_grid": "Overview",
"tab_list": "List",
"total": "Total suppliers",
"avg_rating": "Avg. rating",
"countries": "Countries",
"avg_lead": "Avg. lead time",
"name": "Company name",
"country": "Country",
"category": "Category",
"rating": "Rating",
"moq": "MOQ",
"lead_time": "Lead time (days)",
"contact": "Contact",
"cat_meat": "Meat",
"cat_dairy": "Dairy",
"cat_spices": "Spices",
"cat_packaging": "Packaging",
"cat_logistics": "Logistics",
"cat_ingredients": "Ingredients",
"cat_other": "Other",
"no_suppliers": "No suppliers registered yet.",
"filter_country": "Filter country",
"filter_category": "Filter category"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Cucina · Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0e14",
"theme_color": "#0a0e14",
"lang": "nl",
"icons": [
{
"src": "/static/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+128
View File
@@ -0,0 +1,128 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overzicht",
"cto_home": "CTO Overzicht",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Klanten",
"deals": "Deals",
"products": "Producten",
"suppliers": "Leveranciers",
"intel": "Intel",
"beurs": "Beurs Live",
"retail": "Retail",
"browser": "Browser",
"documents": "Documenten",
"projects": "Projecten",
"hermes": "Hermes",
"telegram": "Telegram",
"agents": "Agents",
"marketing": "Marketing",
"chat": "Chat",
"studio": "Studio",
"packaging": "Packaging",
"reports": "Rapporten",
"system": "Systeem",
"analytics": "Analytics",
"voice": "Voice",
"ops": "IT Ops",
"settings": "Instellingen",
"gitea": "Gitea"
},
"common": {
"save": "Opslaan",
"cancel": "Annuleren",
"delete": "Verwijderen",
"edit": "Bewerken",
"add": "Toevoegen",
"search": "Zoeken",
"refresh": "Verversen",
"loading": "Laden…",
"none": "—",
"yes": "Ja",
"no": "Nee",
"confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
"saved": "Opgeslagen",
"deleted": "Verwijderd",
"error": "Fout",
"all": "Alle",
"actions": "Acties",
"language": "Taal",
"dutch": "Nederlands",
"english": "English"
},
"clients": {
"title": "CRM · Klanten",
"subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
"new": "Nieuwe klant",
"tab_kanban": "Kanban",
"tab_list": "Lijst",
"tab_stores": "Supermarkten",
"tab_360": "360° Klant"
},
"products": {
"title": "Productcatalogus",
"subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
"new": "Nieuw product",
"tab_grid": "Overzicht",
"tab_list": "Lijst",
"tab_margin": "Marge calculator",
"total": "Totaal producten",
"active": "Actief",
"avg_margin": "Gem. marge",
"with_client": "Met klant",
"name": "Productnaam",
"status": "Status",
"margin_pct": "Marge %",
"moq": "MOQ",
"shelf_target": "Schap / listing",
"launch_date": "Launch datum",
"client": "Klant",
"status_concept": "Concept",
"status_development": "Ontwikkeling",
"status_active": "Actief",
"status_discontinued": "Stopgezet",
"calc_cost": "Inkoopprijs",
"calc_sell": "Verkoopprijs",
"calc_profit": "Winst",
"calc_margin": "Marge",
"calc_run": "Berekenen",
"no_products": "Nog geen producten — voeg je eerste SKU toe.",
"drawer_deals": "Gekoppelde klant"
},
"suppliers": {
"title": "Leveranciers",
"subtitle": "Vendor registry · ratings · levertijden · contacten",
"new": "Nieuwe leverancier",
"tab_grid": "Overzicht",
"tab_list": "Lijst",
"total": "Totaal leveranciers",
"avg_rating": "Gem. rating",
"countries": "Landen",
"avg_lead": "Gem. levertijd",
"name": "Bedrijfsnaam",
"country": "Land",
"category": "Categorie",
"rating": "Rating",
"moq": "MOQ",
"lead_time": "Levertijd (dagen)",
"contact": "Contact",
"cat_meat": "Vlees",
"cat_dairy": "Zuivel",
"cat_spices": "Kruiden & specerijen",
"cat_packaging": "Verpakking",
"cat_logistics": "Logistiek",
"cat_ingredients": "Ingrediënten",
"cat_other": "Overig",
"no_suppliers": "Nog geen leveranciers geregistreerd.",
"filter_country": "Filter land",
"filter_category": "Filter categorie"
}
}
+9 -7
View File
@@ -1,10 +1,12 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.4
python-multipart==0.0.12
psycopg2-binary==2.9.9
httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
fastapi==0.115.6
httpx==0.27.2
itsdangerous>=2.1.0
jinja2==3.1.4
openpyxl>=3.1.0
psycopg2-binary==2.9.9
python-multipart==0.0.12
textblob==0.18.0.post0
tweepy==4.15.0
uvicorn[standard]==0.32.1
websockets==13.1
+501
View File
@@ -0,0 +1,501 @@
/* Revenue Cockpit — Excel layout (licht) */
.rc-excel-page {
--rc-green: #92d050;
--rc-red: #ff0000;
--rc-orange: #ffc000;
--rc-yellow: #ffff00;
--rc-blue: #bdd7ee;
--rc-border: #b4b4b4;
--rc-text: #000;
background: #e8e8e8;
color: var(--rc-text);
margin: -1rem -1.25rem 0;
padding: 0.75rem 1rem 2rem;
min-height: calc(100vh - 4rem);
font-family: Calibri, "Segoe UI", Arial, sans-serif;
font-size: 11pt;
}
.persona-ceo .main-topnav:has(.rc-excel-page),
.main-topnav:has(.rc-excel-page) {
background: #e8e8e8;
}
.rc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
gap: 0.5rem;
}
.rc-toolbar-title {
font-weight: 700;
font-size: 0.85rem;
color: #333;
}
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
.rc-save-hint { font-size: 0.72rem; color: #666; }
.rc-saved { color: #217346; }
.rc-btn {
border: 1px solid #aaa;
background: #fff;
border-radius: 3px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
cursor: pointer;
color: #222;
}
.rc-btn:hover { background: #f3f3f3; }
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
.rc-btn-import { font-size: 0.72rem; }
.rc-sheet-wrap {
overflow: auto;
background: #fff;
border: 1px solid #888;
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
}
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
.rc-sheet {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
}
.rc-col-logo { width: 72px; }
.rc-col-deal { width: 16%; }
.rc-col-num { width: 10%; }
.rc-col-steps { width: auto; }
.rc-row td {
border: 1px solid var(--rc-border);
vertical-align: top;
padding: 4px 6px;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
color: #000;
}
/* Header goals — geel */
.rc-row-goals td {
background: var(--rc-yellow);
font-weight: 600;
min-height: 48px;
}
.rc-cell-tagline {
font-size: 14pt;
font-style: italic;
text-align: center;
}
/* Kolomkoppen */
.rc-row-head td {
background: #fff;
font-weight: 700;
border-bottom: 2px solid #666;
}
.rc-th { text-align: left; }
.rc-num { text-align: right; }
/* Row kleuren — exact Excel */
.rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
.rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
.rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
.rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
.rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
.rc-style-white td:not(.rc-logo-cell) { background: #fff; }
.rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
.rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
.rc-cell-edit:focus {
outline: 2px solid #217346;
outline-offset: -2px;
background: #fff !important;
color: #000 !important;
}
.rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
.rc-cell-deal { font-weight: 600; }
.rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
/* Logo kolom */
.rc-logo-cell {
background: #fff !important;
text-align: center;
vertical-align: middle;
padding: 6px 4px;
width: 72px;
}
.rc-logo-cucina .rc-logo-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 6.5pt;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.02em;
text-align: center;
}
.rc-logo-foodlinkk {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
}
.rc-fl-brand {
font-size: 11pt;
font-weight: 900;
color: #111;
letter-spacing: -0.02em;
}
.rc-fl-brand::first-letter { color: #f5c400; }
.rc-fl-sub {
font-size: 5.5pt;
font-weight: 700;
letter-spacing: 0.04em;
color: #333;
text-transform: uppercase;
}
/* Rij panel */
.rc-row-panel {
position: fixed;
bottom: 1rem;
right: 1rem;
width: min(420px, calc(100vw - 2rem));
background: #fff;
border: 1px solid #888;
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
border-radius: 6px;
z-index: 100;
font-size: 0.82rem;
}
.rc-row-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
.rc-inline select, .rc-inline input {
flex: 1;
border: 1px solid #bbb;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
}
.rc-style-select {
border: 1px solid #bbb;
font-size: 0.72rem;
padding: 0.15rem 0.35rem;
}
.rc-footnote {
margin: 0.75rem 0 0;
font-size: 0.72rem;
color: #666;
}
@media (max-width: 768px) {
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
}
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
.rc-page {
--rc-green: #4ade80;
--rc-red: #f87171;
--rc-orange: #fb923c;
--rc-yellow: #facc15;
--rc-blue: #60a5fa;
--rc-green-fill: #22c55e;
--rc-red-fill: #ef4444;
--rc-orange-fill: #f59e0b;
--rc-yellow-fill: #eab308;
--rc-blue-fill: #3b82f6;
color: #e2e8f0;
}
.rc-page.rc-mode-sheet { background: transparent; }
.rc-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.rc-header h1 { margin: 0; font-size: 1.25rem; }
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.rc-view-toggle {
display: flex;
border: 1px solid rgba(148,163,184,0.2);
border-radius: 8px;
overflow: hidden;
}
.rc-toggle-btn {
border: none;
background: transparent;
color: #94a3b8;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
cursor: pointer;
}
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
.rc-summary-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
padding: 0.55rem 0.85rem;
background: rgba(15,20,25,0.6);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 10px;
}
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
.rc-sum-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; }
.rc-sum-item strong { font-size: 0.95rem; color: #34d399; font-variant-numeric: tabular-nums; }
.rc-filter-inline {
flex: 1 1 140px;
min-width: 120px;
max-width: 220px;
padding: 0.35rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(148,163,184,0.18);
background: #0a0e12;
color: #e2e8f0;
font-size: 0.78rem;
margin-left: auto;
}
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
.rc-dot-btn {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(148,163,184,0.3);
background: transparent;
cursor: pointer;
padding: 0;
}
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
.rc-dot-green { background: var(--rc-green); }
.rc-dot-red { background: var(--rc-red); }
.rc-dot-orange { background: var(--rc-orange); }
.rc-dot-yellow { background: var(--rc-yellow); }
.rc-dot-blue { background: var(--rc-blue); }
.rc-dot-white { background: #e2e8f0; }
.rc-visual-wrap {
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(148,163,184,0.12);
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
background: #0a0e12;
}
.rc-visual-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
table-layout: fixed;
background: #0a0e12;
font-family: inherit;
font-size: 0.82rem;
color: #e2e8f0;
}
.rc-col-chart-num { width: 11%; }
.rc-col-donut { width: 64px; }
.rc-visual-goals td {
background: rgba(234, 179, 8, 0.12);
color: #fde68a;
font-weight: 600;
font-size: 0.78rem;
padding: 6px 10px;
border: 1px solid rgba(148,163,184,0.1);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-vtagline { font-style: italic; text-align: center; color: #fbbf24; }
.rc-visual-head td {
background: rgba(15,20,25,0.95);
color: #64748b;
font-weight: 600;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 6px 10px;
border: 1px solid rgba(148,163,184,0.1);
border-bottom: 1px solid rgba(148,163,184,0.2);
}
.rc-visual-row { cursor: pointer; transition: background 0.12s; }
.rc-visual-row:hover td:not(.rc-logo-cell) { background: rgba(30,41,59,0.55) !important; }
.rc-visual-row.rc-row-open td:not(.rc-logo-cell) { background: rgba(30,58,95,0.35) !important; }
.rc-visual-row.rc-row-open { box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
.rc-visual-row td,
.rc-vcell {
border: 1px solid rgba(148,163,184,0.08);
padding: 6px 10px;
vertical-align: middle;
line-height: 1.35;
background: rgba(15,20,25,0.5);
}
.rc-visual-row.rc-style-green td:not(.rc-logo-cell) {
background: rgba(34, 197, 94, 0.1);
box-shadow: inset 3px 0 0 var(--rc-green-fill);
}
.rc-visual-row.rc-style-red td:not(.rc-logo-cell) {
background: rgba(239, 68, 68, 0.1);
box-shadow: inset 3px 0 0 var(--rc-red-fill);
}
.rc-visual-row.rc-style-orange td:not(.rc-logo-cell) {
background: rgba(245, 158, 11, 0.1);
box-shadow: inset 3px 0 0 var(--rc-orange-fill);
}
.rc-visual-row.rc-style-yellow td:not(.rc-logo-cell) {
background: rgba(234, 179, 8, 0.1);
box-shadow: inset 3px 0 0 var(--rc-yellow-fill);
font-weight: 600;
color: #fde68a;
}
.rc-visual-row.rc-style-blue td:not(.rc-logo-cell) {
background: rgba(59, 130, 246, 0.1);
box-shadow: inset 3px 0 0 var(--rc-blue-fill);
}
.rc-visual-row.rc-style-white td:not(.rc-logo-cell) {
background: rgba(15,20,25,0.5);
box-shadow: inset 3px 0 0 #475569;
}
.rc-brand-cucina .rc-vdeal { padding-left: 8px; }
.rc-brand-foodlinkk .rc-vdeal { padding-left: 8px; }
.rc-logo-mini { width: 36px; padding: 2px !important; text-align: center; background: transparent !important; }
.rc-logo-xs .rc-logo-ring { width: 26px; height: 26px; font-size: 11px; font-weight: 900; }
.rc-logo-xs.rc-logo-foodlinkk .rc-fl-brand { font-size: 14px; }
.rc-vdeal { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #f1f5f9; }
.rc-vnum { text-align: right; }
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.8rem; font-weight: 600; color: #34d399; }
.rc-mini-bar {
height: 4px;
background: rgba(148,163,184,0.15);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.rc-mini-bar-fill { height: 100%; border-radius: 2px; min-width: 2px; }
.rc-mini-bar-year { background: #38bdf8 !important; }
.rc-vdonut { text-align: center; padding: 4px !important; }
.rc-donut {
width: 36px; height: 36px;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.rc-donut-label { font-size: 0.58rem; font-weight: 700; color: #e2e8f0; z-index: 1; }
.rc-donut-empty { color: #64748b; font-size: 0.75rem; }
.rc-vsteps {
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 4px; }
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
.rc-expand-cell { padding: 0 !important; }
.rc-expand-inner {
display: flex;
gap: 1rem;
padding: 0.85rem 1rem;
background: rgba(10,14,18,0.98);
color: #e2e8f0;
border-top: 1px solid rgba(56,189,248,0.15);
}
.rc-expand-chart { flex: 0 0 140px; }
.rc-bar-chart {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.5rem;
height: 72px;
margin-bottom: 0.5rem;
}
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
.rc-bar-stack {
width: 28px;
min-height: 2px;
background: var(--rc-green-fill);
border-radius: 3px 3px 0 0;
transition: height 0.2s;
}
.rc-bar-year { background: #38bdf8; }
.rc-expand-stats { font-size: 0.75rem; }
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
.rc-expand-stats span { color: #64748b; }
.rc-expand-stats strong { color: #34d399; }
.rc-expand-body { flex: 1; min-width: 0; }
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
.rc-expand-body p { margin: 0; font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; color: #cbd5e1; }
.rc-expand-task { margin-top: 0.65rem; }
.rc-expand-task .rc-inline select,
.rc-expand-task .rc-inline input {
border: 1px solid rgba(148,163,184,0.25);
background: #0f1419;
color: #e2e8f0;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
border-radius: 4px;
}
.rc-page .rc-excel-page {
margin: 0;
padding: 0;
min-height: auto;
background: #e8e8e8;
color: #000;
border-radius: 8px;
overflow: hidden;
}
@media (max-width: 768px) {
.rc-summary-strip { gap: 0.5rem; }
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
.rc-expand-inner { flex-direction: column; }
}
+483
View File
@@ -0,0 +1,483 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
_pollStop: null,
viewMode: 'cockpit',
visualMode: 'table',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
const saved = localStorage.getItem('rc-visual-mode');
if (saved === 'table' || saved === 'bars' || saved === 'cards') {
this.visualMode = saved;
} else if (window.matchMedia('(max-width: 768px)').matches) {
this.visualMode = 'cards';
}
await this.loadLive();
await this.loadDbQuiet();
if (window.CockpitLive) {
this._pollStop = CockpitLive.startPolling(async () => {
await this.loadLive();
await this.loadDbQuiet();
}, 45000);
}
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
setVisualMode(mode) {
if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return;
this.visualMode = mode;
localStorage.setItem('rc-visual-mode', mode);
},
styleLabel(style) {
const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' };
return map[style] || 'Neutraal';
},
rankedRows() {
const rows = this.visualRows().slice();
rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0));
return rows;
},
cardRows() {
return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim());
},
async setCockpitStyle(style) {
const p = this.selectedProject;
if (!p) return;
if (!p.db_id) {
p.row_style = style;
if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info');
return;
}
try {
await fetch('/api/revenue-cockpit/projects/' + p.db_id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
p.row_style = style;
const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p));
if (pi >= 0) this.projects[pi].row_style = style;
const di = this.dbProjects.findIndex((x) => x.id === p.db_id);
if (di >= 0) this.dbProjects[di].row_style = style;
if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success');
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+269
View File
@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=5" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
<header class="rc-header">
<div>
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
<p class="rc-subtitle" x-show="meta.file_mtime">
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
· <span x-text="fmtDate(meta.file_mtime)"></span>
</p>
</div>
<div class="rc-header-actions">
<div class="rc-view-toggle">
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
</div>
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
</div>
</header>
<!-- Cockpit: Excel-layout met charts -->
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
<div class="rc-summary-strip">
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / mnd</span>
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / jr</span>
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Deals</span>
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
</div>
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
<div class="rc-style-dots">
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
<template x-for="s in styleFilters" :key="'d-' + s.id">
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
</template>
</div>
</div>
<div class="rc-visual-wrap">
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-chart-num" />
<col class="rc-col-chart-num" />
<col class="rc-col-donut" />
<col class="rc-col-steps" />
</colgroup>
<!-- Excel rij 1: goals -->
<tr class="rc-visual-goals">
<td class="rc-logo-cell"></td>
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
</tr>
<tr class="rc-visual-head">
<td class="rc-logo-cell"></td>
<td class="rc-vcell">Deal</td>
<td class="rc-vcell rc-num">Marge mnd</td>
<td class="rc-vcell rc-num">Marge jr</td>
<td class="rc-vcell rc-num">Aandeel</td>
<td class="rc-vcell">Next steps</td>
</tr>
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
<tr class="rc-visual-row"
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<td class="rc-logo-cell rc-logo-mini">
<template x-if="row.logoCell === 'cucina'">
<div class="rc-logo rc-logo-cucina rc-logo-xs"><span class="rc-logo-ring">C</span></div>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<div class="rc-logo rc-logo-foodlinkk rc-logo-xs"><span class="rc-fl-brand">F</span></div>
</template>
</td>
<td class="rc-vcell rc-vdeal" x-text="row.name"></td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_month">
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_year">
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
</div>
</td>
<td class="rc-vcell rc-vdonut">
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
</div>
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
</td>
<td class="rc-vcell rc-vsteps">
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
</td>
</tr>
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
<td class="rc-expand-cell" colspan="6">
<div class="rc-expand-inner">
<div class="rc-expand-chart">
<div class="rc-bar-chart">
<div class="rc-bar-col">
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
<span>Mnd</span>
</div>
<div class="rc-bar-col">
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
<span>Jr</span>
</div>
</div>
<div class="rc-expand-stats">
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
</div>
</div>
<div class="rc-expand-body">
<h4>Next steps</h4>
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
<div class="rc-expand-task" x-show="row.db_id">
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</table>
</div>
</div>
<!-- Bewerken: originele Excel -->
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
<div class="rc-toolbar">
<span class="rc-toolbar-title">Bewerken · DB</span>
<div class="rc-toolbar-actions">
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
</div>
</div>
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
<table class="rc-sheet" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-num" />
<col class="rc-col-num" />
<col class="rc-col-steps" />
</colgroup>
<tr class="rc-row rc-row-goals">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.vision_text || ''"
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.horizon_text || ''"
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.mid_text || ''"
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
x-init="$el.textContent = goals.tagline || ''"
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
</tr>
<tr class="rc-row rc-row-head">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-th">Deal</td>
<td class="rc-cell rc-th rc-num">Marge Month</td>
<td class="rc-cell rc-th rc-num">Marge Year</td>
<td class="rc-cell rc-th">Next steps</td>
</tr>
<template x-for="row in sheetRows" :key="row.id || row.source_row">
<tr class="rc-row rc-data-row"
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
@click="selectRow(row)">
<template x-if="row.logoCell === 'cucina'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
</td>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-foodlinkk">
<span class="rc-fl-brand">Foodlinkk</span>
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
</div>
</td>
</template>
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.name || ''"
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_month)"
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_year)"
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.next_steps || ''"
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
</tr>
</template>
</table>
</div>
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
<div class="rc-row-panel-head">
<strong x-text="selectedRow?.name"></strong>
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
<option value="green">Groen (live)</option>
<option value="red">Rood (inactief)</option>
<option value="orange">Oranje</option>
<option value="yellow">Geel (sectie)</option>
<option value="blue">Blauw</option>
<option value="white">Wit</option>
</select>
</div>
<div class="rc-row-panel-body">
<label>Agent taak
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
</div>
</label>
</div>
</div>
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=4"></script>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+114
View File
@@ -138,6 +138,12 @@
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1200;
display: none;
pointer-events: none;
}
.agent-events-backdrop.is-open {
display: block;
pointer-events: auto;
}
.agent-events-drawer {
position: fixed;
@@ -208,3 +214,111 @@
white-space: pre-wrap;
}
.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
/* Live working animations */
.agent-soul-card {
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
background: rgba(12, 18, 28, 0.85);
}
.agent-soul-card.is-working {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12);
animation: agent-card-pulse 2.2s ease-in-out infinite;
}
.agent-soul-card.is-busy {
border-color: rgba(251, 191, 36, 0.65);
box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12);
animation: agent-card-pulse-busy 1.4s ease-in-out infinite;
}
.agent-soul-card.is-working .agent-char-wrap,
.agent-soul-card.is-busy .agent-char-wrap {
animation: agent-avatar-bob 1.1s ease-in-out infinite;
}
.agent-soul-card.is-working .agent-char-wrap::after,
.agent-soul-card.is-busy .agent-char-wrap::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%);
animation: agent-glow-sweep 2.4s ease-in-out infinite;
}
.agent-soul-card.is-busy .agent-char-wrap::after {
background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%);
}
.agent-activity-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 12px;
margin-left: 0.35rem;
vertical-align: middle;
}
.agent-activity-eq span {
width: 3px;
border-radius: 2px;
background: #38bdf8;
animation: agent-eq 0.9s ease-in-out infinite;
}
.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; }
.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; }
.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; }
.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; }
.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; }
.agent-status-pill.active {
border-color: rgba(34,197,94,0.45);
color: #86efac;
background: rgba(34,197,94,0.1);
}
.agent-status-pill.busy {
border-color: rgba(245,158,11,0.5);
color: #fcd34d;
background: rgba(245,158,11,0.12);
animation: agent-pill-blink 1.2s ease-in-out infinite;
}
.agent-status-pill.idle .dot { background: #64748b; }
.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; }
.agent-status-pill.offline { color: #64748b; }
@keyframes agent-card-pulse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes agent-card-pulse-busy {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.01); }
}
@keyframes agent-avatar-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes agent-glow-sweep {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.85; }
}
@keyframes agent-eq {
0%, 100% { transform: scaleY(0.55); opacity: 0.7; }
50% { transform: scaleY(1.25); opacity: 1; }
}
@keyframes agent-pill-blink {
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
}
/* Brighter, readable agent avatars on dark UI */
.agent-char-wrap {
background: linear-gradient(145deg, #1e4a6e, #163a58) !important;
border: 1px solid rgba(56, 189, 248, 0.35) !important;
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25);
overflow: hidden;
}
.agent-char-wrap .agent-char-svg {
filter: brightness(1.18) contrast(1.08) saturate(1.15);
}
.agent-soul-card .agent-char-wrap {
width: 72px;
height: 72px;
}
+469 -55
View File
@@ -1,103 +1,517 @@
.mesh-wrap {
position: relative;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(7, 11, 18, 0.95) 0%, rgba(15, 23, 42, 0.85) 100%);
overflow: hidden;
transition: box-shadow 0.25s ease, border-color 0.25s ease;
}
.mesh-wrap-mode-mesh { border-color: rgba(168, 85, 247, 0.25); }
.mesh-wrap-mode-org { border-color: rgba(255, 215, 0, 0.2); }
.mesh-wrap-mode-pulse { border-color: rgba(34, 197, 94, 0.25); }
.mesh-wrap-mode-clusters { border-color: rgba(255, 159, 67, 0.25); }
.mesh-wrap-mode-pyramid { border-color: rgba(0, 229, 255, 0.2); }
.mesh-svg {
width: 100%;
min-height: 520px;
min-height: 680px;
display: block;
}
.mesh-tier-band {
fill: rgba(148, 163, 184, 0.03);
stroke: none;
}
.mesh-tier-0 { fill: rgba(0, 229, 255, 0.03); }
.mesh-tier-1 { fill: rgba(168, 85, 247, 0.025); }
.mesh-tier-2 { fill: rgba(34, 197, 94, 0.025); }
.mesh-edge {
stroke: rgba(0, 229, 255, 0.42);
stroke-width: 1.6;
fill: none;
stroke: rgba(100, 116, 139, 0.2);
stroke-linecap: round;
stroke-width: 1.2;
pointer-events: none;
}
.mesh-edge-report {
stroke: rgba(0, 229, 255, 0.45);
stroke-width: 1.5;
}
.mesh-edge-delegate {
stroke: rgba(255, 215, 0, 0.4);
stroke-width: 1.3;
stroke-dasharray: 5 6;
}
.mesh-edge-executive {
stroke: rgba(255, 159, 67, 0.28);
stroke-width: 1.1;
}
.mesh-edge-peer {
stroke: rgba(168, 85, 247, 0.12);
stroke-width: 0.8;
}
.mesh-edge-peer-live {
stroke: rgba(192, 132, 252, 0.55);
stroke-width: 1.4;
}
.mesh-pulse {
stroke: rgba(0, 229, 255, 0.9);
stroke-width: 3;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 3 140;
animation: mesh-pulse 2.4s linear infinite;
stroke-dasharray: 4 100;
animation: mesh-pulse 2.6s linear infinite;
pointer-events: none;
}
.mesh-pulse-active.mesh-edge-report { stroke: rgba(0, 229, 255, 0.85); }
.mesh-pulse-active.mesh-edge-delegate { stroke: rgba(255, 215, 0, 0.75); }
.mesh-pulse-active.mesh-edge-peer-live { stroke: rgba(192, 132, 252, 0.9); }
@keyframes mesh-pulse {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -286; }
to { stroke-dashoffset: -208; }
}
.mesh-node { cursor: pointer; }
.mesh-node text {
font-size: 12px;
fill: #e2e8f0;
font-weight: 600;
.mesh-node text.mesh-label {
font-size: 13px;
fill: #f1f5f9;
font-weight: 700;
text-anchor: middle;
pointer-events: none;
}
.mesh-label-agent { font-size: 13px; }
.mesh-label-herman { font-size: 14px; fill: #ffd700; }
.mesh-label-exec { font-size: 13px; }
.mesh-node text.mesh-sublabel {
font-size: 10px;
fill: #94a3b8;
text-anchor: middle;
pointer-events: none;
}
.mesh-sublabel-herman { fill: #cbd5e1; font-size: 10px; }
.mesh-node text.mesh-context {
font-size: 9.5px;
fill: #64748b;
text-anchor: middle;
pointer-events: none;
}
.mesh-node text.mesh-domain {
font-size: 8.5px;
fill: #475569;
text-anchor: middle;
pointer-events: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mesh-node text.mesh-emoji {
font-size: 14px;
text-anchor: middle;
pointer-events: none;
}
.mesh-herman-crown { font-size: 18px; pointer-events: none; }
.mesh-node circle {
fill: #0f172a;
stroke: rgba(148, 163, 184, 0.65);
.mesh-node circle.main {
fill: #0b1220;
stroke: rgba(148, 163, 184, 0.55);
stroke-width: 2;
}
.mesh-node.healthy circle { stroke: #22c55e; }
.mesh-node.warn circle { stroke: #f59e0b; }
.mesh-node.idle circle { stroke: #38bdf8; }
.mesh-node.offline circle { stroke: #64748b; }
.mesh-node.healthy circle.main { stroke: #22c55e; }
.mesh-node.warn circle.main { stroke: #f59e0b; }
.mesh-node.idle circle.main { stroke: #475569; }
.mesh-node.offline circle.main { stroke: #334155; opacity: 0.65; }
.mesh-herman circle.main {
fill: #111827;
stroke: #ffd700;
stroke-width: 4;
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
}
.mesh-exec circle.main {
fill: #0f172a;
stroke-width: 3;
}
.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
.mesh-exec circle.ring {
.mesh-agent-live .mesh-pulse-wrap circle.pulse-ring {
fill: none;
stroke: rgba(255, 255, 255, 0.15);
stroke-width: 1.5;
stroke-dasharray: 4 4;
}
.mesh-agent .mesh-status { fill: #64748b; }
.mesh-herman circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.35);
stroke-width: 2;
stroke-dasharray: 8 4;
animation: mesh-herman-ring 7s linear infinite;
stroke: rgba(34, 197, 94, 0.45);
stroke-width: 1.8;
animation: mesh-agent-pulse 2.8s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
@keyframes mesh-herman-ring {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@keyframes mesh-agent-pulse {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.75; transform: scale(1.12); }
}
.mesh-herman circle.main {
fill: #0f172a;
stroke: #ffd700;
stroke-width: 3;
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
}
.mesh-herman.active .mesh-herman-ring circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.4);
stroke-width: 2;
stroke-dasharray: 10 8;
}
.mesh-status-live circle.main { stroke-width: 2.5; }
.mesh-status-ready circle.main { stroke: #64748b; }
.mesh-status-warn circle.main { stroke: #f59e0b; }
.mesh-status-idle circle.main { stroke: #334155; opacity: 0.7; }
.mesh-exec circle.main { stroke-width: 2.5; }
.mesh-ceo circle.main { stroke: #ff9f43; }
.mesh-cto circle.main { stroke: #a855f7; }
/* ——— Communicatie-modus kiezer ——— */
.mesh-mode-section {
margin-bottom: 1rem;
}
.mesh-mode-section > label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
margin-bottom: 0.55rem;
font-weight: 600;
}
.mesh-mode-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 1100px) {
.mesh-mode-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
.mesh-mode-grid { grid-template-columns: repeat(2, 1fr); }
}
.mesh-mode-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
padding: 0.7rem 0.75rem 0.65rem;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(145deg, rgba(15, 23, 42, 0.7) 0%, rgba(7, 11, 18, 0.9) 100%);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
min-height: 5.5rem;
color: inherit;
font: inherit;
width: 100%;
}
.mesh-mode-card:hover {
border-color: rgba(148, 163, 184, 0.35);
transform: translateY(-1px);
}
.mesh-mode-card.is-active {
border-color: rgba(0, 229, 255, 0.55);
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 4px 20px rgba(0, 229, 255, 0.08);
background: linear-gradient(145deg, rgba(0, 229, 255, 0.06) 0%, rgba(7, 11, 18, 0.95) 100%);
}
.mesh-mode-card[data-mode="mesh"].is-active {
border-color: rgba(168, 85, 247, 0.55);
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15), 0 4px 20px rgba(168, 85, 247, 0.08);
}
.mesh-mode-card[data-mode="org"].is-active {
border-color: rgba(255, 215, 0, 0.45);
box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.12), 0 4px 20px rgba(255, 215, 0, 0.06);
}
.mesh-mode-card[data-mode="pulse"].is-active {
border-color: rgba(34, 197, 94, 0.5);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 4px 20px rgba(34, 197, 94, 0.08);
}
.mesh-mode-card[data-mode="clusters"].is-active {
border-color: rgba(255, 159, 67, 0.5);
box-shadow: 0 0 0 1px rgba(255, 159, 67, 0.12), 0 4px 20px rgba(255, 159, 67, 0.08);
}
.mesh-mode-icon {
font-size: 1.15rem;
line-height: 1;
opacity: 0.85;
}
.mesh-mode-title {
font-size: 0.82rem;
font-weight: 700;
color: #f1f5f9;
line-height: 1.2;
}
.mesh-mode-sub {
font-size: 0.68rem;
color: #64748b;
line-height: 1.2;
}
.mesh-mode-desc {
font-size: 0.65rem;
color: #94a3b8;
line-height: 1.35;
margin-top: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mesh-mode-check {
position: absolute;
top: 0.45rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 1.5px solid rgba(148, 163, 184, 0.35);
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.mesh-mode-card.is-active .mesh-mode-check {
opacity: 1;
background: #00e5ff;
border-color: #00e5ff;
box-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
}
.mesh-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
margin-bottom: 0.65rem;
}
.mesh-mode-badge {
margin-right: auto;
font-size: 0.78rem;
font-weight: 700;
color: #00e5ff;
padding: 0.25rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
}
.mesh-framework-head { margin-bottom: 0.5rem; }
.mesh-framework-head h2 { margin: 0; }
.mesh-legend {
display: flex;
gap: 0.8rem;
gap: 0.65rem 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
margin-top: 0.65rem;
color: #94a3b8;
font-size: 0.78rem;
}
.mesh-legend span::before {
content: '';
display: inline-block;
width: 1rem;
height: 2px;
margin-right: 0.3rem;
vertical-align: middle;
border-radius: 2px;
}
.mesh-legend .leg-report::before { background: #00e5ff; }
.mesh-legend .leg-delegate::before { background: #ffd700; }
.mesh-legend .leg-peer::before { background: #a855f7; }
.mesh-legend .leg-exec::before { background: #ff9f43; }
.mesh-cluster-label {
font-size: 11px;
fill: #94a3b8;
font-weight: 600;
pointer-events: none;
}
.mesh-agent-cluster-growth circle.main { stroke: rgba(0, 229, 255, 0.7); }
.mesh-agent-cluster-product circle.main { stroke: rgba(168, 85, 247, 0.7); }
.mesh-agent-cluster-ops circle.main { stroke: rgba(34, 197, 94, 0.65); }
.mesh-edge-straight { stroke-dasharray: none; }
/* ——— Terminals grid ——— */
.agents-terminals-intro {
margin: 0 0 1rem;
color: #94a3b8;
font-size: 0.88rem;
}
.agents-terminals-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.85rem;
}
.agent-term-window {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 12px;
background: #060a10;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 200px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.agent-term-window.is-live {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
}
.agent-term-window.is-expanded {
grid-column: 1 / -1;
min-height: 320px;
}
.agent-term-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: rgba(15, 23, 42, 0.85);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
font-size: 0.82rem;
}
.agent-term-head .term-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
}
.agent-term-window.is-live .term-dot {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
animation: term-dot-blink 1.8s ease-in-out infinite;
}
@keyframes term-dot-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.agent-term-head strong { color: #f1f5f9; flex: 1; }
.agent-term-head small { color: #64748b; font-size: 0.72rem; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-term-body {
flex: 1;
overflow-y: auto;
max-height: 180px;
padding: 0.5rem 0.65rem;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
line-height: 1.45;
}
.agent-term-window.is-expanded .agent-term-body {
max-height: 280px;
}
.term-line {
padding: 0.2rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
display: flex;
gap: 0.5rem;
}
.term-ts { color: #64748b; flex-shrink: 0; min-width: 2.5rem; }
.term-msg { color: #cbd5e1; word-break: break-word; }
.term-line.line-handoff .term-msg { color: #c4b5fd; }
.term-line.line-action .term-msg { color: #86efac; }
.term-line.line-command .term-msg { color: #86efac; font-weight: 600; }
.term-line.line-output .term-msg { color: #e2e8f0; }
.term-line.line-error .term-msg { color: #fca5a5; }
.term-line.term-idle .term-msg { color: #64748b; font-style: italic; }
.term-line.term-idle code { color: #94a3b8; font-size: 0.95em; }
.agent-term-foot {
border-top: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(10, 14, 20, 0.95);
padding: 0.45rem 0.55rem;
}
.agent-term-cmd {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
}
.term-prompt {
color: #22c55e;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
font-weight: 700;
flex-shrink: 0;
user-select: none;
}
.agent-term-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: #f1f5f9;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
outline: none;
padding: 0.2rem 0;
}
.agent-term-input::placeholder { color: #475569; }
.agent-term-input.is-busy { opacity: 0.55; }
.agent-term-window.is-running .term-dot {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.7);
}
.agent-term-window.is-expanded .agent-term-foot {
padding: 0.55rem 0.75rem;
}
.agent-term-window.is-expanded .agent-term-input {
font-size: 0.78rem;
}
.mesh-terminal-focus {
margin-top: 1rem;
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 12px;
background: #060a10;
}
.mesh-terminal-focus .agent-term-body {
max-height: 240px;
}
.vtab-badge {
display: inline-flex;
min-width: 1.1rem;
+698
View File
@@ -0,0 +1,698 @@
/* Foodlinkk War Room — compact, one viewport */
.wr-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 0.6rem;
height: calc(100vh - 150px);
max-height: calc(100vh - 150px);
}
.wr-page-header { margin-bottom: 0.4rem; }
.wr-page-header h1 { font-size: 1.2rem; margin: 0; }
.wr-page-header .hm-sub { margin: 0.15rem 0 0; font-size: 0.85rem; }
.wr-roster {
border-right: 1px solid rgba(255,255,255,0.08);
padding-right: 0.4rem;
overflow: auto;
min-height: 0;
}
.wr-roster h3 {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.7;
margin: 0 0 0.35rem;
}
.wr-room-btn, .wr-agent-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.45rem;
text-align: left;
background: transparent;
border: 1px solid transparent;
color: inherit;
padding: 0.32rem 0.4rem;
border-radius: 8px;
cursor: pointer;
}
.wr-room-btn:hover, .wr-agent-btn:hover,
.wr-room-btn.active, .wr-agent-btn.active {
background: rgba(255, 215, 0, 0.08);
border-color: rgba(255, 215, 0, 0.25);
}
.wr-agent-meta { display: flex; flex-direction: column; min-width: 0; }
.wr-agent-meta strong { font-size: 0.8rem; }
.wr-agent-meta small { opacity: 0.65; font-size: 0.68rem; }
.wr-roster .agent-char-wrap { width: 40px; height: 40px; flex: 0 0 40px; }
.wr-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
}
.wr-head {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 0.5rem; margin-bottom: 0.35rem; flex: 0 0 auto;
}
.wr-head h2 { margin: 0; font-size: 1rem; letter-spacing: 0.04em; }
.wr-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.wr-log {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.wr-empty { opacity: 0.6; margin: 0.35rem 0; font-size: 0.85rem; }
.wr-bubble {
display: flex; gap: 0.45rem; margin-bottom: 0.45rem;
}
.wr-bubble.is-user { flex-direction: row-reverse; }
.wr-bubble.is-system { opacity: 0.85; }
.wr-avatar { width: 44px; height: 44px; flex: 0 0 44px; }
.wr-avatar img, .wr-avatar .agent-char-wrap {
width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
}
.wr-avatar .agent-char-wrap svg { display: block; width: 44px; height: 44px; }
.wr-avatar-fallback {
display: grid; place-items: center; width: 44px; height: 44px;
border-radius: 50%; background: #1e3a5f; font-size: 0.65rem; font-weight: 700;
}
.wr-bubble-body {
max-width: min(680px, 85%);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 0.35rem 0.55rem;
}
.wr-bubble.is-user .wr-bubble-body {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.28);
}
.wr-bubble.is-agent .wr-bubble-body {
background: rgba(56, 189, 248, 0.08);
border-color: rgba(56, 189, 248, 0.22);
}
.wr-bubble.is-typing .wr-bubble-body { border-style: dashed; }
.wr-bubble-meta { font-size: 0.68rem; opacity: 0.7; margin-bottom: 0.1rem; }
.wr-bubble-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.88rem;
line-height: 1.3;
}
.wr-typing-cursor::after {
content: "▌";
animation: wrBlink 1s steps(1) infinite;
margin-left: 1px;
}
@keyframes wrBlink { 50% { opacity: 0; } }
.wr-attach-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; }
.wr-attach-card {
display: block; max-width: 300px; text-decoration: none; color: inherit;
border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; overflow: hidden;
background: rgba(0,0,0,0.2);
}
.wr-attach-card img { display: block; width: 100%; max-height: 220px; object-fit: cover; }
.wr-attach-card span {
display: block; padding: 0.25rem 0.4rem; font-size: 0.7rem; opacity: 0.85;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wr-composer {
margin-top: 0.4rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.35rem;
align-items: end;
flex: 0 0 auto;
}
.wr-composer-tools {
grid-column: 1 / -1;
display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
}
.wr-pending {
display: flex; flex-wrap: wrap; gap: 0.3rem; grid-column: 1 / -1;
}
.wr-pending-chip {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.7rem; padding: 0.15rem 0.4rem;
border-radius: 999px; background: rgba(255,255,255,0.08);
}
.wr-pending-chip button {
border: 0; background: transparent; color: inherit; cursor: pointer; opacity: 0.7;
}
.wr-emoji-pop {
display: flex; flex-wrap: wrap; gap: 0.15rem;
max-width: 280px; padding: 0.3rem;
background: #0f172a; border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
}
.wr-emoji-pop button {
border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; line-height: 1;
padding: 0.12rem;
}
/* Call overlay */
.wr-call-overlay {
position: fixed; inset: 0; z-index: 11000;
background: rgba(3, 8, 18, 0.82);
display: none; align-items: center; justify-content: center;
padding: 1rem; pointer-events: none;
}
.wr-call-panel {
width: min(1100px, 96vw);
height: min(780px, 92vh);
background: #0b1220;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 14px;
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
.wr-call-head {
display: flex; justify-content: space-between; align-items: center;
gap: 0.75rem; padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.wr-call-head h3 { margin: 0; font-size: 1rem; }
.wr-call-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.wr-call-frame {
flex: 1; border: 0; width: 100%; background: #000;
}
.wr-call-hint {
margin: 0; padding: 0.45rem 0.85rem; font-size: 0.78rem; opacity: 0.75;
border-top: 1px solid rgba(255,255,255,0.06);
}
.wr-popup-body {
margin: 0; height: 100vh; overflow: hidden;
background: #070b14; color: #e8eef8;
}
.wr-popup-shell { padding: 0.55rem; height: 100vh; box-sizing: border-box; }
.wr-popup-shell .wr-layout {
height: calc(100vh - 1.1rem);
max-height: calc(100vh - 1.1rem);
}
@media (max-width: 900px) {
.wr-layout {
grid-template-columns: 1fr;
height: auto;
max-height: none;
}
.wr-roster { max-height: 140px; border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.wr-main { height: calc(100vh - 280px); }
}
.wr-composer-wrap { position: relative; grid-column: 1 / 2; }
.wr-mention-pop {
position: absolute;
left: 0; right: 0; bottom: calc(100% + 4px);
max-height: 220px; overflow: auto;
background: #0f172a;
border: 1px solid rgba(255,215,0,0.35);
border-radius: 10px;
z-index: 20;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.wr-mention-item {
width: 100%; display: flex; align-items: center; gap: 0.45rem;
text-align: left; border: 0; background: transparent; color: inherit;
padding: 0.4rem 0.55rem; cursor: pointer;
}
.wr-mention-item:hover, .wr-mention-item.active {
background: rgba(255, 215, 0, 0.12);
}
.wr-mention-item strong { font-size: 0.85rem; }
.wr-mention-item small { opacity: 0.65; margin-left: 0.25rem; }
.wr-agent-btn .wr-dm-hint {
margin-left: auto; font-size: 0.65rem; opacity: 0.45;
}
.wr-bubble-meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
.wr-msg-del {
margin-left: auto;
opacity: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.75rem;
padding: 0 0.2rem;
line-height: 1;
}
.wr-bubble:hover .wr-msg-del { opacity: 0.55; }
.wr-msg-del:hover { opacity: 1 !important; color: #e74c3c; }
.wr-call-join {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
min-height: 280px;
}
.wr-call-title { margin: 0; font-size: 1.15rem; font-weight: 600; }
.wr-call-join-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
.wr-call-link {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
opacity: 0.75;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: rgba(255,255,255,0.06);
}
.wr-main { position: relative; z-index: 1; }
.wr-composer {
position: relative;
z-index: 6;
pointer-events: auto !important;
}
.wr-composer textarea,
.wr-composer .form-input {
pointer-events: auto !important;
position: relative;
z-index: 7;
}
.wr-log { position: relative; z-index: 1; }
.wr-call-overlay {
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
}
.btn.is-recording, button.is-recording {
background: #c0392b !important;
color: #fff !important;
animation: wr-mic-pulse 1s ease-in-out infinite;
}
@keyframes wr-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(192,57,43,0.5); }
50% { box-shadow: 0 0 0 8px rgba(192,57,43,0); }
}
.wr-voice-overlay {
position: fixed !important; inset: 0 !important; z-index: 11000 !important;
background: rgba(3, 8, 18, 0.88);
display: none !important;
align-items: center; justify-content: center;
padding: 1rem;
pointer-events: none !important;
visibility: hidden !important;
}
.wr-voice-overlay.is-open {
display: flex !important;
pointer-events: auto !important;
visibility: visible !important;
}
.wr-voice-panel {
width: min(920px, 96vw);
max-height: 92vh;
overflow: auto;
background: #0b1220;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
padding: 1rem 1.1rem 1.15rem;
box-shadow: 0 24px 70px rgba(0,0,0,0.5);
color: #e8eef8;
}
.wr-voice-head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.wr-voice-head h3 { margin: 0; font-size: 1.15rem; }
.wr-voice-head-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.wr-voice-status {
margin: 0.85rem 0 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: rgba(255,255,255,0.05);
font-size: 0.9rem;
min-height: 1.4em;
}
.wr-voice-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.65rem;
}
.wr-voice-card {
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
padding: 0.75rem 0.5rem;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.03);
text-align: center;
cursor: pointer;
transition: border-color .15s, box-shadow .15s, opacity .15s;
}
.wr-voice-card.is-human { cursor: default; border-color: rgba(56, 189, 248, 0.35); }
.wr-voice-card.is-live {
border-color: rgba(34, 197, 94, 0.7);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
}
.wr-voice-card.is-off { opacity: 0.45; }
.wr-voice-avatar {
width: 52px; height: 52px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
overflow: hidden; background: #1e293b;
}
.wr-voice-avatar-human {
font-weight: 700; font-size: 0.85rem; background: #0ea5e9; color: #041018;
}
.wr-voice-avatar .agent-char-wrap, .wr-voice-avatar.agent-char-wrap { width: 52px; height: 52px; }
.wr-voice-card strong { font-size: 0.82rem; }
.wr-voice-card small { font-size: 0.7rem; }
.wr-voice-hint { font-size: 0.8rem; opacity: 0.8; margin: 0.75rem 0 0.25rem; }
.wr-voice-ptt { display: flex; justify-content: center; margin: 1rem 0 0.5rem; }
.wr-ptt-btn {
min-width: min(320px, 90vw);
min-height: 64px;
border: 0; border-radius: 999px;
font-size: 1.05rem; font-weight: 600;
background: linear-gradient(135deg, #0ea5e9, #2563eb);
color: #fff; cursor: pointer;
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
}
.wr-ptt-btn.is-recording {
background: linear-gradient(135deg, #dc2626, #b91c1c);
animation: wr-mic-pulse 1s ease-in-out infinite;
}
.wr-ptt-btn.is-busy { opacity: 0.7; cursor: wait; }
.wr-voice-foot {
display: flex; justify-content: space-between; align-items: center; gap: 0.75rem;
flex-wrap: wrap; margin-top: 0.85rem; padding-top: 0.75rem;
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 0.78rem;
}
/* War Room roster / message avatars — brighter */
.wr-roster .agent-char-wrap,
.wr-avatar .agent-char-wrap {
background: linear-gradient(145deg, #245a82, #1a4568) !important;
border: 1px solid rgba(56, 189, 248, 0.4) !important;
border-radius: 10px;
overflow: hidden;
}
.wr-roster .agent-char-wrap .agent-char-svg,
.wr-avatar .agent-char-wrap .agent-char-svg {
filter: brightness(1.22) contrast(1.1) saturate(1.2);
}
.wr-chan-icon {
display: inline-flex;
width: 16px;
height: 16px;
color: #38bdf8;
margin-right: 0.35rem;
vertical-align: -2px;
}
.wr-chan-icon svg { width: 16px; height: 16px; display: block; }
/* Agents vertical tabs — SVG icons */
.vtabs-nav .vtab-icon-svg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
color: #7dd3fc;
background: color-mix(in srgb, #22d3ee 14%, rgba(15,23,42,.6));
border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent);
font-size: 0;
}
.vtabs-nav .vtab-icon-svg svg { width: 15px; height: 15px; display: block; }
.vtabs-nav .vtab-btn.active .vtab-icon-svg {
color: #fff;
background: color-mix(in srgb, #22d3ee 45%, #0b1220);
border-color: #22d3ee;
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35);
}
/* War Room richer toolbar / search / dates */
.wr-head-title { min-width: 0; }
.wr-msg-count { display: block; margin-top: 0.15rem; font-size: 0.7rem; opacity: 0.65; }
.wr-tool-btn.active {
border-color: rgba(34, 211, 238, 0.55) !important;
background: rgba(34, 211, 238, 0.15) !important;
}
.wr-tools-wrap { position: relative; }
.wr-tools-menu {
position: absolute; right: 0; top: calc(100% + 4px); z-index: 40;
min-width: 200px;
background: #0f2438;
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 10px;
padding: 0.35rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
display: flex; flex-direction: column; gap: 0.15rem;
}
.wr-tools-menu button {
text-align: left; background: transparent; border: 0; color: #e2e8f0;
padding: 0.45rem 0.55rem; border-radius: 7px; cursor: pointer; font-size: 0.8rem;
}
.wr-tools-menu button:hover { background: rgba(56, 189, 248, 0.12); }
.wr-tools-menu button.wr-danger { color: #fda4af; }
.wr-tools-menu button.wr-danger:hover { background: rgba(244, 63, 94, 0.15); }
.wr-tools-menu hr {
border: 0; border-top: 1px solid rgba(148,163,184,0.2); margin: 0.25rem 0;
}
.wr-searchbar {
display: flex; gap: 0.4rem; align-items: center;
margin-bottom: 0.4rem; flex: 0 0 auto;
}
.wr-searchbar .form-input { flex: 1; min-width: 0; font-size: 0.85rem; padding: 0.4rem 0.65rem; }
.wr-day-sep {
display: flex; align-items: center; justify-content: center;
margin: 0.75rem 0 0.55rem;
}
.wr-day-sep span {
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
padding: 0.2rem 0.7rem;
border-radius: 999px;
}
.wr-bubble-meta {
display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
}
.wr-who { font-weight: 600; color: #e2e8f0; opacity: 1; }
.wr-handle { opacity: 0.55; font-size: 0.65rem; }
.wr-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.55;
font-size: 0.65rem;
}
.wr-bubble.is-user .wr-time { margin-left: 0; margin-right: auto; order: -1; }
.wr-a2a-btn {
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
}
.wr-a2a-btn.is-on {
background: rgba(34, 197, 94, 0.18) !important;
border: 1px solid rgba(34, 197, 94, 0.45) !important;
color: #86efac !important;
}
.wr-a2a-btn.is-off {
background: rgba(148, 163, 184, 0.12) !important;
border: 1px solid rgba(148, 163, 184, 0.35) !important;
color: #94a3b8 !important;
}
.wr-mobile-bar { display: none; }
/* CEO inbox */
.wr-ceo-btn { position: relative; }
.wr-ceo-badge {
display: inline-grid; place-items: center;
min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem;
margin-left: 0.25rem; border-radius: 999px;
background: #f43f5e; color: #fff; font-size: 0.65rem; font-weight: 700;
}
.wr-ceo-overlay {
position: absolute; inset: 0; z-index: 80;
background: rgba(2, 8, 20, 0.72);
display: flex; align-items: stretch; justify-content: flex-end;
border-radius: 10px;
}
.wr-main { position: relative; }
.wr-ceo-panel {
width: min(420px, 100%);
background: linear-gradient(180deg, #0f2744, #0a1c33);
border-left: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: -12px 0 40px rgba(0,0,0,0.45);
display: flex; flex-direction: column;
padding: 0.75rem;
}
.wr-ceo-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.65rem; }
.wr-ceo-head h3 { margin: 0; font-size: 0.95rem; }
.wr-ceo-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: flex-start; }
.wr-ceo-list { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.wr-ceo-item {
border: 1px solid rgba(148,163,184,0.18);
background: rgba(15, 23, 42, 0.55);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.wr-ceo-item.unread {
border-color: rgba(251, 191, 36, 0.55);
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08);
}
.wr-ceo-item-meta {
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
font-size: 0.72rem; opacity: 0.85; margin-bottom: 0.35rem;
}
.wr-ceo-unread-dot {
background: #fbbf24; color: #111; font-weight: 700;
padding: 0.05rem 0.4rem; border-radius: 999px; font-size: 0.65rem;
}
.wr-ceo-item-body {
white-space: pre-wrap; word-break: break-word;
font-size: 0.86rem; line-height: 1.35;
}
.wr-ceo-item-actions { margin-top: 0.4rem; }
.wr-bubble.is-ceo-direct .wr-bubble-body {
border-color: rgba(251, 191, 36, 0.55) !important;
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08) !important;
}
/* Reply */
.wr-msg-reply {
margin-left: auto;
border: 0;
background: transparent;
color: inherit;
opacity: 0;
cursor: pointer;
font-size: 0.68rem;
padding: 0 0.2rem;
}
.wr-bubble:hover .wr-msg-reply { opacity: 0.7; }
.wr-msg-reply:hover { opacity: 1 !important; color: var(--accent, #3b82f6); }
.wr-reply-quote {
border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
padding: 0.2rem 0.45rem;
margin: 0.15rem 0 0.35rem;
font-size: 0.72rem;
opacity: 0.85;
background: color-mix(in srgb, var(--surface-2, #111) 60%, transparent);
border-radius: 0 6px 6px 0;
}
.wr-reply-quote-who { display: block; font-weight: 600; margin-bottom: 0.1rem; }
.wr-reply-quote-text { display: block; opacity: 0.85; white-space: pre-wrap; }
.wr-reply-bar {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
margin-bottom: 0.25rem;
border-radius: 8px;
border-left: 3px solid var(--accent, #3b82f6);
background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
font-size: 0.78rem;
}
.wr-reply-bar-text { flex: 1; min-width: 0; }
.wr-reply-bar-text strong { display: block; margin-bottom: 0.1rem; }
.wr-reply-bar-text span {
display: block;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid color-mix(in srgb, var(--accent, #38bdf8) 70%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, #38bdf8 18%, transparent);
}
.wr-msg-wrap { scroll-margin: 4rem; }
.wr-log-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.wr-log-wrap .wr-log {
flex: 1 1 auto;
min-height: 0;
}
.wr-jump-latest {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 25;
border: 1px solid rgba(56, 189, 248, 0.45);
background: rgba(8, 20, 36, 0.94);
color: #e0f2fe;
border-radius: 999px;
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
}
.wr-jump-latest:hover {
background: rgba(14, 40, 64, 0.98);
border-color: rgba(56, 189, 248, 0.7);
}
.wr-history-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.45rem 0.35rem 0.65rem;
position: sticky;
top: 0;
z-index: 5;
background: linear-gradient(180deg, rgba(8,12,18,0.96), rgba(8,12,18,0.72) 70%, transparent);
}
.wr-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0.85;
}
.wr-day-sep {
display: flex;
justify-content: center;
margin: 0.75rem 0 0.5rem;
}
.wr-day-sep span {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.2);
opacity: 0.9;
}
+326 -43
View File
@@ -1,50 +1,333 @@
.analytics-shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
/* Foodlinkk Intelligence Analytics — lush neo dashboard */
.ax-shell {
--ax-bg: #070b12;
--ax-panel: rgba(12, 18, 28, 0.88);
--ax-line: rgba(125, 211, 252, 0.16);
--ax-cyan: #38bdf8;
--ax-lime: #b8ff3c;
--ax-amber: #fbbf24;
--ax-rose: #fb7185;
--ax-text: #e8eef7;
--ax-muted: #8fa3b8;
--ax-font: "Outfit", "Segoe UI", sans-serif;
--ax-mono: "JetBrains Mono", ui-monospace, monospace;
font-family: var(--ax-font);
color: var(--ax-text);
margin: -0.25rem -0.5rem 0;
padding: 0 0 2.5rem;
position: relative;
}
.analytics-filters label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #94a3b8;
}
.analytics-filters select,
.analytics-filters input {
width: 100%;
margin-top: 0.25rem;
}
.analytics-main { min-width: 0; }
.analytics-kpis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
.ax-shell * { box-sizing: border-box; }
.ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; }
.ax-muted { color: var(--ax-muted); font-size: 0.82rem; }
.ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; }
.ax-hero {
position: relative;
overflow: hidden;
border-radius: 22px;
border: 1px solid var(--ax-line);
background:
radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%),
radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%),
linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%);
padding: 1.4rem 1.5rem 1.2rem;
margin-bottom: 1rem;
animation: ax-fade-up .55s ease both;
}
.analytics-kpis .kpi-card {
padding: 0.75rem 1rem;
border-radius: 10px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.15);
.ax-hero-glow {
position: absolute; inset: auto -20% -40% 40%; height: 70%;
background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%);
pointer-events: none; filter: blur(8px);
}
.analytics-kpis .kpi-card strong {
display: block;
font-size: 1.35rem;
color: #e2e8f0;
}
.analytics-kpis .kpi-card span {
font-size: 0.75rem;
color: #64748b;
}
.analytics-charts {
.ax-hero-grid {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
grid-template-columns: 1.4fr auto 1fr;
gap: 1.25rem;
align-items: center;
}
.chart-panel canvas { max-height: 220px; }
.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; }
@media (max-width: 900px) {
.analytics-shell { grid-template-columns: 1fr; }
.ax-eyebrow {
margin: 0 0 0.35rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.ax-hero h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.05;
}
.ax-live {
display: inline-flex; align-items: center;
margin-left: 0.55rem; vertical-align: middle;
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em;
padding: 0.22rem 0.55rem; border-radius: 999px;
background: rgba(184,255,60,0.12); border: 1px solid rgba(184,255,60,0.35);
color: #d9ff8b; animation: ax-pulse 2.4s ease-in-out infinite;
}
.ax-lead { margin: 0.65rem 0 0; max-width: 36rem; color: #b6c7d8; font-size: 1rem; line-height: 1.5; }
.ax-goal { margin: 0.55rem 0 0; color: #7dd3fc; font-size: 0.82rem; opacity: 0.9; }
.ax-pulse-orb { text-align: center; position: relative; width: 150px; justify-self: center; }
.ax-ring { width: 150px; height: 150px; transform: rotate(-90deg); }
.ax-ring-track { fill: none; stroke: rgba(148,163,184,0.15); stroke-width: 8; }
.ax-ring-value {
fill: none; stroke: var(--ax-cyan); stroke-width: 8; stroke-linecap: round;
transition: stroke-dasharray .8s ease;
filter: drop-shadow(0 0 8px rgba(56,189,248,0.45));
}
.tone-good .ax-ring-value { stroke: var(--ax-lime); filter: drop-shadow(0 0 8px rgba(184,255,60,0.4)); }
.tone-mid .ax-ring-value { stroke: var(--ax-amber); }
.tone-low .ax-ring-value { stroke: var(--ax-rose); }
.ax-pulse-core {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center;
transform: translateY(-6px);
}
.ax-pulse-core strong { font-size: 2.1rem; font-weight: 700; line-height: 1; }
.ax-pulse-core span { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ax-muted); }
.ax-pulse-label { margin: 0.15rem 0 0; font-size: 0.75rem; color: #cbd5e1; }
.ax-hero-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: flex-end; align-items: center; }
.ax-btn {
appearance: none; border: 1px solid rgba(148,163,184,0.28);
background: rgba(8,12,20,0.55); color: #e2e8f0;
border-radius: 999px; padding: 0.45rem 0.85rem; font: inherit; font-size: 0.78rem;
cursor: pointer; text-decoration: none; transition: border-color .15s, background .15s, transform .12s;
}
.ax-btn:hover { border-color: #38bdf8; background: rgba(14,165,233,0.12); transform: translateY(-1px); }
.ax-btn-primary {
border-color: rgba(184,255,60,0.45);
background: linear-gradient(135deg, rgba(184,255,60,0.18), rgba(56,189,248,0.12));
color: #f8ffe8;
}
.ax-updated { width: 100%; text-align: right; color: #64748b; font-size: 0.7rem; }
.ax-filters {
margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid rgba(148,163,184,0.12);
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.65rem; align-items: end;
}
.ax-filters label { font-size: 0.72rem; color: #94a3b8; display: grid; gap: 0.25rem; }
.ax-filters select, .ax-filters input {
width: 100%; border-radius: 8px; border: 1px solid rgba(148,163,184,0.25);
background: #0a1018; color: #e2e8f0; padding: 0.4rem 0.5rem; font: inherit;
}
.ax-signals {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
animation: ax-fade-up .6s ease both .08s;
}
.ax-signal {
position: relative; overflow: hidden;
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 16px;
padding: 0.85rem 0.9rem 0.75rem;
min-height: 96px;
}
.ax-signal::after {
content: ""; position: absolute; inset: auto 0 0 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--ax-cyan), transparent);
opacity: 0.55;
}
.ax-signal.good::after { background: linear-gradient(90deg, transparent, var(--ax-lime), transparent); }
.ax-signal.warn::after { background: linear-gradient(90deg, transparent, var(--ax-amber), transparent); }
.ax-signal-label { display: block; font-size: 0.68rem; color: #94a3b8; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-signal strong { display: block; margin-top: 0.35rem; font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; }
.ax-signal em { display: block; margin-top: 0.25rem; font-style: normal; font-size: 0.72rem; color: #64748b; }
.ax-split {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem;
animation: ax-fade-up .65s ease both .12s;
}
.ax-col {
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 18px;
padding: 1rem;
min-height: 320px;
}
.ax-col-win { box-shadow: inset 3px 0 0 rgba(184,255,60,0.55); }
.ax-col-opp { box-shadow: inset 3px 0 0 rgba(251,191,36,0.55); }
.ax-col-head {
display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem;
margin-bottom: 0.85rem;
}
.ax-col-head h2, .ax-hermes h2, .ax-radar h2 {
margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
}
.ax-col-head span, .ax-pill {
font-size: 0.72rem; color: #94a3b8;
padding: 0.2rem 0.55rem; border-radius: 999px;
border: 1px solid rgba(148,163,184,0.2);
}
.ax-insight {
display: block; text-decoration: none; color: inherit;
border: 1px solid rgba(148,163,184,0.12);
background: rgba(6, 10, 16, 0.55);
border-radius: 14px;
padding: 0.8rem 0.85rem;
margin-bottom: 0.55rem;
transition: transform .14s, border-color .14s, background .14s;
}
.ax-insight:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.35);
background: rgba(14,165,233,0.08);
}
.ax-insight-top { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.ax-insight-top strong { font-size: 0.95rem; line-height: 1.3; }
.ax-score {
font-size: 0.85rem; color: #d9ff8b; min-width: 2rem; text-align: right;
}
.ax-insight.opp .ax-score { color: #fcd34d; }
.ax-insight p { margin: 0.4rem 0 0.55rem; font-size: 0.82rem; color: #a7b7c8; line-height: 1.45; }
.ax-insight-foot { display: grid; gap: 0.35rem; }
.ax-insight-foot > span:first-child { font-size: 0.72rem; color: #7dd3fc; }
.ax-bar, .ax-mini-bar {
display: block; height: 4px; border-radius: 999px; background: rgba(148,163,184,0.15); overflow: hidden;
}
.ax-bar i, .ax-mini-bar i {
display: block; height: 100%; border-radius: inherit;
background: linear-gradient(90deg, #84cc16, #b8ff3c);
}
.ax-bar.opp i { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.ax-tag {
display: inline-block; font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.4rem; border-radius: 999px; margin-right: 0.25rem;
background: rgba(251,191,36,0.12); color: #fcd34d; border: 1px solid rgba(251,191,36,0.25);
}
.ax-empty { color: #64748b; font-size: 0.85rem; padding: 1rem 0; }
.ax-band { margin-bottom: 1rem; animation: ax-fade-up .7s ease both .16s; }
.ax-att-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.ax-att-chip {
flex: 1 1 220px; text-decoration: none; color: inherit;
border: 1px solid rgba(251,113,133,0.28);
background: rgba(251,113,133,0.08);
border-radius: 12px; padding: 0.7rem 0.8rem;
}
.ax-att-chip strong { display: block; font-size: 0.85rem; margin-bottom: 0.2rem; }
.ax-att-chip span { font-size: 0.75rem; color: #cbd5e1; }
.ax-attention h3 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #fda4af; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-hermes {
border: 1px solid var(--ax-line);
border-radius: 18px;
background:
radial-gradient(700px 220px at 0% 0%, rgba(56,189,248,0.12), transparent 55%),
var(--ax-panel);
padding: 1rem 1.1rem 1.1rem;
}
.ax-findings-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.65rem; margin-top: 0.75rem;
}
.ax-finding {
border-radius: 14px; border: 1px solid rgba(148,163,184,0.16);
background: rgba(5,9,14,0.65); padding: 0.85rem;
}
.ax-finding.kind-win { border-color: rgba(184,255,60,0.3); }
.ax-finding.kind-opportunity { border-color: rgba(251,191,36,0.35); }
.ax-finding-top { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
.ax-finding-top time { font-size: 0.68rem; color: #64748b; }
.ax-finding h4 { margin: 0; font-size: 0.95rem; }
.ax-finding p { margin: 0.4rem 0 0.55rem; font-size: 0.8rem; color: #a7b7c8; line-height: 1.45; }
.ax-finding-foot { display: flex; justify-content: space-between; font-size: 0.7rem; color: #64748b; }
.ax-findings-empty { margin-top: 0.75rem; }
.ax-findings-placeholder {
border: 1px dashed rgba(56,189,248,0.35);
border-radius: 14px; padding: 1.1rem 1.2rem;
background: rgba(14,165,233,0.05);
}
.ax-findings-placeholder strong { display: block; margin-bottom: 0.3rem; }
.ax-findings-placeholder p { margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
.ax-radar {
border: 1px solid var(--ax-line);
border-radius: 18px;
background: var(--ax-panel);
padding: 1rem 1.1rem 1.15rem;
margin-bottom: 1rem;
animation: ax-fade-up .75s ease both .2s;
}
.ax-charts {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
}
.ax-chart-card {
grid-column: span 2;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
background: rgba(5,9,14,0.55);
padding: 0.75rem 0.8rem 0.6rem;
}
.ax-chart-card.wide { grid-column: span 6; }
.ax-chart-card h3 { margin: 0 0 0.45rem; font-size: 0.82rem; color: #cbd5e1; font-weight: 600; }
.ax-chart-wrap { position: relative; height: 210px; }
.ax-chart-card.wide .ax-chart-wrap { height: 180px; }
.ax-lists {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.85rem;
animation: ax-fade-up .8s ease both .24s;
}
.ax-list-card {
border: 1px solid var(--ax-line);
border-radius: 16px;
background: var(--ax-panel);
padding: 0.9rem 1rem;
}
.ax-list-card h3 { margin: 0 0 0.75rem; font-size: 0.92rem; }
.ax-list-card ul { list-style: none; margin: 0; padding: 0; }
.ax-list-card li {
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
padding: 0.55rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-rank {
width: 1.5rem; height: 1.5rem; border-radius: 8px;
display: grid; place-items: center; font-size: 0.72rem;
background: rgba(56,189,248,0.12); color: #7dd3fc;
}
.ax-list-card li strong { display: block; font-size: 0.82rem; }
.ax-list-card li em { font-style: normal; color: #fbbf24; font-size: 0.85rem; }
.ax-events li { grid-template-columns: 1fr auto; }
.ax-events li span { display: block; font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
.ax-events time { font-size: 0.68rem; color: #64748b; }
.ax-dl { margin: 0; display: grid; gap: 0.55rem; }
.ax-dl > div {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.45rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-dl dt { color: #94a3b8; font-size: 0.8rem; }
.ax-dl dd { margin: 0; font-size: 0.9rem; color: #e2e8f0; }
@keyframes ax-fade-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes ax-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(184,255,60,0.0); }
50% { box-shadow: 0 0 0 6px rgba(184,255,60,0.08); }
}
@media (max-width: 1100px) {
.ax-hero-grid { grid-template-columns: 1fr; }
.ax-hero-actions { justify-content: flex-start; }
.ax-signals { grid-template-columns: repeat(3, 1fr); }
.ax-split, .ax-lists { grid-template-columns: 1fr; }
.ax-charts { grid-template-columns: 1fr 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 2; }
}
@media (max-width: 700px) {
.ax-signals { grid-template-columns: 1fr 1fr; }
.ax-charts { grid-template-columns: 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 1; }
}
+255
View File
@@ -0,0 +1,255 @@
:root { --sidebar-width: 280px; }
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
.app-shell {
display: block;
min-height: 100vh;
}
.app-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width, 280px);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0.75rem 0.6rem 0;
background: #080c12;
border-right: 1px solid rgba(148, 163, 184, 0.1);
}
.app-main {
margin-left: var(--sidebar-width, 280px);
min-width: 0;
min-height: 100vh;
}
.app-sidebar-brand,
.persona-switch.brand-switch {
flex-shrink: 0;
}
.app-sidebar-brand {
padding: 0.25rem 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.app-sidebar-brand .brand-title {
font-size: 0.75rem;
letter-spacing: 0.1em;
color: #64748b;
margin: 0;
}
.app-sidebar-brand .brand-name {
font-size: 1.05rem;
font-weight: 700;
color: #e2e8f0;
margin: 0.2rem 0 0;
}
.persona-switch.brand-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
padding: 0 0.35rem 0.65rem;
margin-bottom: 0.35rem;
}
.persona-btn {
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.6);
color: #94a3b8;
border-radius: 8px;
padding: 0.45rem 0.25rem;
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
}
.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
.persona-nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.75rem;
scrollbar-gutter: stable;
}
.app-sidebar-group {
margin-bottom: 0.65rem;
}
.app-sidebar-label {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
padding: 0.2rem 0.5rem 0.35rem;
}
/* Base pill — accent via modifier class */
.app-sidebar a.nav-pill {
--nav-accent: #64748b;
display: flex !important;
align-items: center;
gap: 0.5rem;
width: 100%;
margin: 0 0 0.2rem;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(15, 23, 42, 0.5);
color: #cbd5e1 !important;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
text-align: left;
white-space: nowrap;
transform: none !important;
animation: none !important;
box-shadow: none !important;
text-shadow: none !important;
overflow: visible;
isolation: auto;
}
.app-sidebar a.nav-pill::before,
.app-sidebar a.nav-pill::after {
display: none !important;
content: none !important;
}
.app-sidebar a.nav-pill:hover {
background: rgba(30, 41, 59, 0.8);
border-color: rgba(148, 163, 184, 0.2);
color: #f1f5f9 !important;
transform: none !important;
}
.app-sidebar a.nav-pill.active {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
border-left: 3px solid var(--nav-accent);
padding-left: calc(0.55rem - 2px);
color: #fff !important;
box-shadow: none !important;
animation: none !important;
}
/* SVG line icons in sidebar */
.sidebar-nav-icon {
width: 30px;
height: 30px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 0;
color: var(--nav-accent, #7dd3fc);
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 14%, rgba(255,255,255,.06));
border: 1px solid color-mix(in srgb, var(--nav-accent, #22d3ee) 35%, transparent);
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
}
.sidebar-nav-icon svg {
width: 16px;
height: 16px;
display: block;
}
.app-sidebar a.nav-pill:hover .sidebar-nav-icon {
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 24%, rgba(255,255,255,.08));
border-color: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, transparent);
}
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
color: #fff;
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, #0b1220);
border-color: var(--nav-accent, #22d3ee);
box-shadow: 0 0 12px color-mix(in srgb, var(--nav-accent, #22d3ee) 45%, transparent);
}
.sidebar-nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.86rem;
}
/* Per-item accent colors */
.app-sidebar a.nav-pill-wereldexport { --nav-accent: #0ea5e9; }
.app-sidebar a.nav-pill-clients { --nav-accent: #38bdf8; }
.app-sidebar a.nav-pill-deals { --nav-accent: #4ade80; }
.app-sidebar a.nav-pill-products { --nav-accent: #a855f7; }
.app-sidebar a.nav-pill-suppliers { --nav-accent: #fb923c; }
.app-sidebar a.nav-pill-chat { --nav-accent: #fcd34d; }
.app-sidebar a.nav-pill-reports { --nav-accent: #6366f1; }
.app-sidebar a.nav-pill-herman { --nav-accent: #fbbf24; }
.app-sidebar a.nav-pill-retail { --nav-accent: #0066cc; }
.app-sidebar a.nav-pill-marketing { --nav-accent: #ff6b9d; }
.app-sidebar a.nav-pill-beurs { --nav-accent: #b8ff3c; }
.app-sidebar a.nav-pill-documents { --nav-accent: #94a3b8; }
.app-sidebar a.nav-pill-telegram { --nav-accent: #2aabee; }
.app-sidebar a.nav-pill-analytics { --nav-accent: #8b5cf6; }
.app-sidebar a.nav-pill-agents { --nav-accent: #00e5ff; }
.app-sidebar a.nav-pill-browser { --nav-accent: #14b8a6; }
.app-sidebar a.nav-pill-studio { --nav-accent: #ec4899; }
.app-sidebar a.nav-pill-voice { --nav-accent: #f97316; }
.app-sidebar a.nav-pill-ops { --nav-accent: #22d3ee; }
.app-sidebar a.nav-pill-settings { --nav-accent: #e2e8f0; }
.persona-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.08em;
padding: 0.15rem 0.45rem;
border-radius: 6px;
margin-right: 0.45rem;
}
.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 !important; }
.app-main { margin-left: 0 !important; }
.app-sidebar {
z-index: 1000;
overflow-y: auto;
overflow-x: hidden;
}
}
+220 -7
View File
@@ -11,15 +11,60 @@
.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
.clients-tabs {
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.clients-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.88);
border: 1px solid rgba(56, 189, 248, 0.14);
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.clients-tab {
border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
white-space: nowrap;
}
.clients-tab-icon {
font-size: 0.9rem;
line-height: 1;
opacity: 0.85;
}
.clients-tab:hover {
color: #e2e8f0;
background: rgba(56, 189, 248, 0.07);
}
.clients-tab.active {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(168, 85, 247, 0.1));
border-color: rgba(56, 189, 248, 0.38);
box-shadow: 0 0 18px rgba(56, 189, 248, 0.1);
}
.clients-tab.active .clients-tab-icon { opacity: 1; }
.clients-tab-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
@media (max-width: 720px) {
.clients-tabs { flex-direction: column; align-items: stretch; }
.clients-tab-bar { width: 100%; }
.clients-tab-actions { margin-left: 0; width: 100%; }
}
.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
@@ -39,7 +84,30 @@
.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; margin-bottom: 0.45rem; }
.clients-card-stage {
width: 100%;
margin-top: 0.15rem;
padding: 0.28rem 0.45rem;
font-size: 0.68rem;
font-weight: 600;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(0, 0, 0, 0.35);
color: #e2e8f0;
cursor: pointer;
}
.clients-card-stage:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15); }
.clients-inline-stage {
min-width: 7.5rem;
padding: 0.25rem 0.4rem;
font-size: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(0, 0, 0, 0.35);
color: #e2e8f0;
}
.clients-inline-stage:focus { outline: none; border-color: #38bdf8; }
.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
.stage-intake { border-left: 3px solid #94a3b8; }
@@ -76,6 +144,103 @@
display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
}
@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
.store-filters-card {
background: rgba(13, 18, 25, 0.75);
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
padding: 0.85rem 1rem;
margin-bottom: 1rem;
}
.store-filters-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 1100px) { .store-filters-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) { .store-filters-grid { grid-template-columns: 1fr 1fr; } }
.store-filter-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.store-filter-field .filter-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.store-filter-search { grid-column: span 2; }
@media (max-width: 640px) { .store-filter-search { grid-column: span 2; } }
.store-filters-extra {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.store-filters-extra summary {
cursor: pointer;
font-size: 0.78rem;
color: #7dd3fc;
user-select: none;
margin-bottom: 0.45rem;
}
.store-filter-checks {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1rem;
font-size: 0.78rem;
color: #cbd5e1;
}
.store-filter-checks label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.store-filters-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.store-results-meta {
margin-left: auto;
font-size: 0.72rem;
color: #94a3b8;
}
.store-results-col { min-width: 0; }
.store-table-wrap {
max-height: 420px;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
}
.store-results-table { font-size: 0.78rem; margin: 0; }
.store-results-table th {
position: sticky;
top: 0;
background: #0d1219;
z-index: 1;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
}
.store-table-row { cursor: pointer; }
.store-table-row:hover td { background: rgba(56, 189, 248, 0.06); }
.store-table-row.selected td {
background: rgba(56, 189, 248, 0.12);
box-shadow: inset 3px 0 0 #38bdf8;
}
.store-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 0.55rem;
font-size: 0.78rem;
}
.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.store-list-item {
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
@@ -91,3 +256,51 @@
.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
/* ── 360° Klant (CRM) ── */
.crm-360-shell {
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
min-height: 560px;
}
.crm-360-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(56, 189, 248, 0.06), transparent 70%);
}
.crm-360-head h2 { margin: 0; font-size: 1.05rem; color: #f1f5f9; }
.crm-360-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
.crm-360-section {
margin: 0 0 0.5rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
}
.crm-360-stat {
padding: 0.65rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(0, 0, 0, 0.25);
text-align: center;
}
.crm-360-stat .n { font-size: 1.2rem; font-weight: 800; color: #f1f5f9; }
.crm-360-stat .l { font-size: 0.6rem; color: #64748b; text-transform: uppercase; }
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
.a360-links { max-height: 200px; overflow-y: auto; }
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); color: #cbd5e1; }
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(56,189,248,0.12); color: #7dd3fc; }
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
+162
View File
@@ -0,0 +1,162 @@
/* Cockpit readability — loads last, harmonizes all modules */
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-size: var(--text-base, 1rem);
line-height: var(--leading, 1.55);
}
/* Headings */
h1 { font-size: var(--text-3xl, 1.75rem); font-weight: 700; letter-spacing: -0.02em; }
h2 { font-size: var(--text-xl, 1.25rem); font-weight: 600; }
h3 { font-size: var(--text-lg, 1.125rem); font-weight: 600; }
h4 { font-size: var(--text-md, 1.0625rem); font-weight: 600; }
.page-header h1,
.ei-header h1,
.herman-shell.hm-neo .herman-hero h1 {
font-size: 1.75rem !important;
}
.page-header .subtitle,
.ei-subtitle,
.ei-header .ei-subtitle {
font-size: 0.9375rem !important;
line-height: 1.5;
}
.ei-tab-panel-head h2,
.panel-inner h2,
.briefing-card h3 {
font-size: 1.125rem !important;
}
/* Body text minimum */
p, li, td, label, .page-tip {
font-size: max(0.9375rem, 1em);
}
/* Tables */
.ei-table,
.data-table,
.voice-results-table,
.clients-table,
.deals-table {
font-size: 0.9375rem !important;
}
.ei-table th,
.data-table th {
font-size: 0.8125rem !important;
font-weight: 600;
}
.ei-table td,
.data-table td {
padding: 0.6rem 0.75rem !important;
}
/* Buttons & inputs */
.btn {
font-size: 0.9375rem !important;
font-weight: 500;
}
.btn-sm {
font-size: 0.875rem !important;
}
.form-input,
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
font-size: 0.9375rem !important;
}
/* Sidebar nav */
.app-sidebar a.nav-pill {
font-size: 0.9375rem !important;
}
.app-sidebar-label {
font-size: 0.75rem !important;
font-weight: 600;
}
.app-topbar,
.app-clock {
font-size: 0.875rem !important;
}
/* KPI cards */
.ei-neo-kpi-label,
.hm-neo-kpi-label,
.briefing-stat-label,
.stat-label {
font-size: 0.8125rem !important;
}
.ei-neo-kpi-value,
.hm-neo-kpi-value,
.briefing-stat-value {
font-size: 1.65rem !important;
}
/* Tabs & filters */
.vtab-btn,
.vtab-btn-rich,
.ei-filter-label,
.ei-type-chip {
font-size: 0.9375rem !important;
}
.ei-filter-group input,
.ei-filter-group select {
font-size: 0.9375rem !important;
}
/* Badges & pills — readable but compact */
.ei-type-pill,
.ei-halal-pill,
.badge,
.ei-crm-badge,
.live-badge {
font-size: 0.8125rem !important;
}
/* Herman dashboard micro-labels */
.hm-neo-kpi-label,
.hm-section-label,
.herman-shell.hm-neo .hm-card-label {
font-size: 0.8125rem !important;
letter-spacing: 0.06em !important;
}
/* Export intel */
.ei-sync-msg,
.ei-selection-hint,
.ei-map-truncated {
font-size: 0.875rem !important;
}
.ei-drawer-title {
font-size: 1.35rem !important;
}
.ei-drawer-kv,
.ei-popup-row {
font-size: 0.875rem !important;
}
/* Mobile — never shrink below readable */
@media (max-width: 768px) {
html { font-size: 16px; }
.app-sidebar a.nav-pill { font-size: 0.9375rem !important; }
.page-header h1 { font-size: 1.5rem !important; }
}
+877
View File
@@ -0,0 +1,877 @@
/* Documents & NAS Workbench v2 */
[x-cloak] { display: none !important; }
.doc-hub {
--doc-accent: #00e5ff;
--doc-gold: #ffd700;
--doc-purple: #a855f7;
--doc-border: rgba(0, 229, 255, 0.14);
--doc-text: #f1f5f9;
--doc-muted: #94a3b8;
--doc-dim: #64748b;
}
.doc-hub .page-header h1 {
font-size: 1.65rem;
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Tabs */
.doc-nav {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.85);
border: 1px solid var(--doc-border);
border-radius: 14px;
}
.doc-nav-btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--doc-muted);
transition: all 0.18s;
user-select: none;
}
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
.doc-nav-btn.active,
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"] {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
}
/* Workbench shell */
.doc-workbench {
border-radius: 16px;
border: 1px solid var(--doc-border);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
}
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
.doc-tab-input {
position: fixed;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
#doc-tab-stage {
min-height: 50vh;
}
#doc-tab-stage [data-tab-panel] {
display: none !important;
}
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
#doc-tab-stage [data-tab-panel].tab-active {
display: block !important;
min-height: 50vh;
}
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
font-size: 0.65rem;
color: var(--doc-dim);
white-space: nowrap;
}
.dl-version-bar {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
}
.doc-row-fallback { opacity: 0.85; }
.share-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
margin-bottom: 0.5rem;
font-size: 0.68rem;
}
.share-crumb {
background: transparent;
border: none;
color: #7dd3fc;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 4px;
}
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
.share-crumb-sep { color: #475569; }
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
.share-browser-list { max-height: 52vh; overflow-y: auto; }
.doc-wb-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
}
.doc-wb-head h2 {
margin: 0;
font-size: 1.05rem;
color: var(--doc-text);
}
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
.doc-kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
.doc-kpi {
padding: 0.9rem 1.1rem;
border-right: 1px solid rgba(148, 163, 184, 0.06);
cursor: pointer;
transition: background 0.15s;
}
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
.doc-kpi:last-child { border-right: none; }
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
.doc-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
/* Split layout */
.doc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
min-height: 280px;
}
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
.doc-split-col {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-split-col:last-child { border-right: none; }
.doc-chart-wrap { height: 140px; position: relative; }
/* Document table */
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
.doc-table-wrap .data-table { font-size: 0.78rem; }
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
/* Label chips */
.doc-chip {
display: inline-block;
padding: 0.18rem 0.55rem;
margin: 0.12rem 0.12rem 0 0;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
background: rgba(0, 229, 255, 0.12);
color: #7dd3fc;
border: 1px solid rgba(0, 229, 255, 0.25);
white-space: nowrap;
}
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
/* Photo grid */
.photo-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.photo-tile {
border: 1px solid var(--doc-border);
border-radius: 14px;
overflow: hidden;
background: rgba(10, 15, 26, 0.95);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.photo-tile:hover {
transform: translateY(-2px);
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
}
.photo-tile img {
width: 100%;
height: 150px;
object-fit: cover;
background: #060a10;
display: block;
}
.photo-tile-body {
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.photo-tile-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
}
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
.photo-tile-labels {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-height: 1.5rem;
align-items: flex-start;
}
.photo-tile-ocr {
font-size: 0.68rem;
line-height: 1.4;
color: var(--doc-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.8em;
}
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
/* Label filter chips row */
.label-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0 1rem 0.75rem;
}
.label-filter-chip {
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.35);
background: transparent;
color: var(--doc-muted);
}
.label-filter-chip.active, .label-filter-chip:hover {
border-color: rgba(0, 229, 255, 0.4);
color: #e0f2fe;
background: rgba(0, 229, 255, 0.1);
}
/* ── Label modal (fixed overlap + dark text) ── */
.label-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.label-modal-backdrop.open {
display: flex;
}
.label-modal {
width: min(780px, 100%);
max-height: 92vh;
overflow-y: auto;
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 20px;
padding: 1.35rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
color: var(--doc-text);
}
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
.label-modal-preview {
display: grid;
grid-template-columns: 140px 1fr;
gap: 1rem;
margin: 1rem 0;
padding: 0.85rem;
background: rgba(0, 0, 0, 0.35);
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
}
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
.label-modal-preview img {
width: 100%;
border-radius: 10px;
object-fit: cover;
max-height: 160px;
border: 1px solid rgba(148, 163, 184, 0.15);
}
.modal-ocr-box {
font-size: 0.74rem;
line-height: 1.55;
color: #cbd5e1 !important;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 8px;
padding: 0.65rem 0.75rem;
max-height: 140px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.modal-ocr-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
margin-bottom: 0.35rem;
}
.label-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 0.5rem;
}
.label-pick {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
min-height: 92px;
border-radius: 12px;
border: 1px solid rgba(100, 116, 139, 0.35);
background: rgba(15, 23, 42, 0.7);
cursor: pointer;
transition: all 0.15s;
color: #e2e8f0;
}
.label-pick:hover {
border-color: rgba(0, 229, 255, 0.4);
background: rgba(0, 229, 255, 0.06);
}
.label-pick.selected {
border-color: var(--pick-color, #00e5ff);
background: rgba(0, 229, 255, 0.14);
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
}
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
.label-pick-name {
font-size: 0.68rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
color: #cbd5e1;
display: block;
width: 100%;
word-break: break-word;
}
.label-pick.selected .label-pick-name { color: #f8fafc; }
.label-modal-extra {
margin-top: 1rem;
display: grid;
gap: 0.5rem;
}
.label-modal-extra label {
font-size: 0.72rem;
color: var(--doc-muted);
display: block;
margin-bottom: 0.25rem;
}
.label-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
/* Document label drawer */
.doc-label-drawer {
margin: 1rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid rgba(0, 229, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
}
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
/* ── Docling workbench ── */
.dl-workbench { min-height: 620px; }
.dl-body {
display: grid;
grid-template-columns: 260px 300px 1fr;
min-height: 560px;
}
/* v3 split workbench: files | editor+preview */
.dl-body-v3 {
display: grid;
grid-template-columns: minmax(260px, 300px) 1fr;
min-height: 580px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
.dl-pipeline-panel {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.18);
}
.dl-pipeline-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
.dl-workspace {
display: flex;
flex-direction: column;
min-height: 580px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.dl-workspace.empty { justify-content: center; align-items: center; }
.dl-workspace-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.dl-ws-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
flex-wrap: wrap;
}
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
.dl-workspace-panes {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
gap: 0;
}
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
.dl-pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.06);
}
.dl-pane:last-child { border-right: none; }
.dl-pane-label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0.85rem;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
gap: 0.5rem;
flex-wrap: wrap;
}
.dl-export-tabs-inline { margin: 0; }
.dl-editor-full {
flex: 1;
min-height: 420px;
max-height: none;
border: none;
border-radius: 0;
resize: none;
}
.dl-preview-viewport {
flex: 1;
min-height: 420px;
overflow: auto;
padding: 0.65rem;
}
.dl-preview-viewport .dl-preview-box {
min-height: 200px;
max-height: none;
height: 100%;
}
.dl-preview-img {
max-width: 100%;
max-height: 480px;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.dl-preview-iframe {
width: 100%;
height: 480px;
border: none;
border-radius: 8px;
background: #fff;
}
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
.dl-file-meta { flex: 1; min-width: 0; }
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
.dl-body-v3 .dl-sidebar {
max-height: none;
height: 100%;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 1200px) {
.dl-body-v3 { grid-template-columns: 1fr; }
.dl-body-v3 .dl-sidebar { max-height: 280px; }
}
@media (max-width: 1200px) {
.dl-body { grid-template-columns: 240px 1fr; }
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
}
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
overflow-y: auto;
max-height: 680px;
}
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
.dl-options { background: rgba(0, 0, 0, 0.12); }
.dl-section-title {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--doc-dim);
margin: 0 0 0.5rem;
}
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-ext-chip {
padding: 0.22rem 0.5rem;
border-radius: 6px;
font-size: 0.65rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
.dl-file-item {
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.12s;
}
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
.dl-file-item.selected {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.1);
}
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-badge {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.58rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
.dl-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(100, 116, 139, 0.2);
font-size: 0.76rem;
color: var(--doc-muted);
cursor: pointer;
transition: all 0.12s;
}
.dl-toggle:has(input:checked) {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
color: #e0f2fe;
}
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
.dl-preview-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-tab {
padding: 0.32rem 0.7rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-preview-box {
min-height: 320px;
max-height: 480px;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.1);
border-radius: 10px;
padding: 0.85rem;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
}
.dl-preview-box.rendered { white-space: normal; }
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
.dl-stat {
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(148, 163, 184, 0.1);
}
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
.dl-history-item {
padding: 0.45rem 0.55rem;
border-radius: 8px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--doc-muted);
}
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
.dl-progress {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
overflow: hidden;
margin-top: 0.5rem;
}
.dl-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00e5ff, #a855f7);
animation: dl-pulse 1.2s ease-in-out infinite;
width: 40%;
}
.dl-editor {
width: 100%;
min-height: 360px;
max-height: 520px;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.76rem;
line-height: 1.55;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.2);
border-radius: 10px;
padding: 0.85rem;
color: #e2e8f0;
resize: vertical;
}
.ppt-workbench { min-height: 560px; }
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
.ppt-editor { padding: 1rem; }
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
/* ── Document chat (RAG) ── */
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
.doc-chat-input .form-input { flex: 1; }
@keyframes dl-pulse {
0%, 100% { transform: translateX(-100%); width: 30%; }
50% { transform: translateX(200%); width: 50%; }
}
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
@@ -0,0 +1,51 @@
.doc-tabs { display:flex; gap:0.35rem; flex-wrap:wrap; margin-bottom:1rem; }
.doc-tab {
padding:0.45rem 0.9rem; border-radius:999px; font-size:0.78rem; cursor:pointer;
border:1px solid rgba(0,229,255,0.15); background:rgba(13,18,25,0.8); color:#94a3b8;
}
.doc-tab.active { background:rgba(0,229,255,0.15); color:#e0f2fe; border-color:rgba(0,229,255,0.45); }
.label-modal-backdrop {
position:fixed; inset:0; z-index:1200; background:rgba(0,0,0,0.72);
display:flex; align-items:center; justify-content:center; padding:1rem;
}
.label-modal {
width:min(720px, 100%); max-height:90vh; overflow:auto;
background:linear-gradient(160deg,#0d1219,#080c12); border:1px solid rgba(0,229,255,0.2);
border-radius:16px; padding:1.1rem; box-shadow:0 20px 60px rgba(0,0,0,0.5);
}
.label-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.85rem; }
.label-modal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:0.45rem; }
.label-pick {
display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.65rem 0.4rem;
border-radius:12px; border:1px solid rgba(100,116,139,0.35); background:rgba(15,23,42,0.6);
cursor:pointer; font-size:0.72rem; text-align:center; transition:all .15s;
}
.label-pick:hover { border-color:rgba(0,229,255,0.35); }
.label-pick.selected { border-color:var(--pick-color, #00e5ff); background:rgba(0,229,255,0.12); box-shadow:0 0 0 1px var(--pick-color, #00e5ff); }
.label-pick-emoji { font-size:1.35rem; }
.docling-grid { display:grid; grid-template-columns:280px 1fr 1fr; gap:1rem; }
@media (max-width:1100px) { .docling-grid { grid-template-columns:1fr; } }
.docling-panel { border:1px solid rgba(0,229,255,0.12); border-radius:12px; padding:0.85rem; background:rgba(13,18,25,0.85); }
.docling-file-list { max-height:420px; overflow:auto; }
.docling-file-row {
padding:0.45rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.75rem;
border:1px solid transparent; margin-bottom:0.25rem;
}
.docling-file-row:hover { background:rgba(0,229,255,0.06); }
.docling-file-row.selected { border-color:rgba(0,229,255,0.35); background:rgba(0,229,255,0.1); }
.docling-opt-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.35rem 0.75rem; font-size:0.78rem; }
.docling-export-tabs { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.65rem 0; }
.docling-export-tab {
padding:0.3rem 0.65rem; border-radius:999px; font-size:0.72rem; cursor:pointer;
border:1px solid rgba(100,116,139,0.35); background:transparent; color:#94a3b8;
}
.docling-export-tab.active { border-color:#00e5ff; color:#e0f2fe; background:rgba(0,229,255,0.12); }
.docling-preview {
max-height:360px; overflow:auto; font-size:0.74rem; line-height:1.45;
background:#060a10; border:1px solid rgba(0,229,255,0.1); border-radius:8px; padding:0.65rem;
white-space:pre-wrap; word-break:break-word;
}
.docling-preview.html-preview { white-space:normal; }
.docling-status { font-size:0.72rem; color:#94a3b8; margin-top:0.35rem; }
+183
View File
@@ -0,0 +1,183 @@
/* Wereldexport — full-width map workspace */
.wereldexport-workspace.vtabs-layout {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 0.65rem !important;
width: 100%;
min-height: calc(100dvh - 220px);
}
.wereldexport-workspace > .vtabs-nav {
flex: 0 0 auto !important;
width: 100% !important;
max-width: none !important;
position: sticky;
top: 0;
z-index: 60;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 0.35rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0.65rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.wereldexport-workspace > .vtabs-nav::before { display: none; }
.wereldexport-workspace .vtabs-nav-header { display: none; }
.wereldexport-workspace > .vtabs-content {
flex: 1 1 auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: flex;
flex-direction: column;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
flex: 0 0 auto;
min-width: 128px;
max-width: 168px;
grid-template-columns: auto 1fr;
padding: 0.45rem 0.55rem;
}
.wereldexport-workspace .ei-vtab-meta,
.wereldexport-workspace .ei-vtab-key,
.wereldexport-workspace .ei-vtab-sub { display: none; }
.wereldexport-workspace .vtab-btn.active {
box-shadow: inset 0 -2px 0 var(--tab-accent, #0ea5e9);
}
.ei-tab-panel:has(.ei-workspace-body) {
background: rgba(8, 11, 16, 0.95);
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 280px);
border-radius: 12px;
overflow: hidden;
}
.ei-tab-panel:has(.ei-workspace-body-map) .ei-tab-panel-head {
display: none;
}
.ei-workspace-body,
.ei-workspace-body-map {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0 !important;
}
.ei-workspace-body-map .ei-map-legend {
flex-shrink: 0;
margin: 0;
padding: 0.45rem 0.75rem;
font-size: 0.68rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.ei-workspace-body-map .ei-map-empty {
flex-shrink: 0;
margin: 0;
padding: 0 0.75rem 0.45rem;
}
.ei-workspace-body-map .ei-map-empty-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.55rem 0.75rem;
}
.ei-workspace-body-map .ei-map-empty-inner strong {
display: inline;
margin: 0;
font-size: 0.78rem;
}
.ei-workspace-body-map .ei-map-empty-inner p {
flex: 1 1 200px;
margin: 0;
font-size: 0.72rem;
}
.ei-workspace-body-map .ei-map-empty-actions {
margin: 0;
flex-shrink: 0;
}
.ei-workspace-body-map .ei-map-truncated,
.ei-workspace-body-map .ei-map-error {
flex-shrink: 0;
margin: 0 0.75rem 0.35rem;
}
.ei-workspace-body-map > .ei-split.ei-split-side {
flex: 1 1 auto;
min-height: 420px;
}
@media (min-width: 900px) {
.ei-workspace-body-map > .ei-split.ei-split-side {
min-height: calc(100dvh - 320px);
}
}
@media (max-width: 899px) {
.wereldexport-workspace > .vtabs-nav {
flex-wrap: nowrap;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
min-width: 110px;
}
}
.ei-neo-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
.ei-neo-kpi {
padding: 0.65rem 0.75rem;
border-radius: 10px;
background: rgba(10, 14, 20, 0.85);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
.ei-neo-ring {
width: 32px; height: 32px; border-radius: 50%;
background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
display: flex; align-items: center; justify-content: center;
}
.ei-neo-ring-inner {
width: 26px; height: 26px; border-radius: 50%;
background: #0a0e14;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
}
.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.ei-sync-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
}
.ei-sync-bar .btn { font-size: 0.72rem; }
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
/* Foodlinkk UI + Sidebar themes
* UI: <html data-theme="..."> localStorage: flk-theme
* Sidebar:<html data-sidebar="..."> localStorage: flk-sidebar-theme
*/
/* ========== UI THEMES (1–5 existing) ========== */
html[data-theme="ocean"] {
--bg-root: #07131f; --bg-surface: #0c1c2e; --bg-elevated: #123049; --bg-hover: #183a58;
--border-subtle: #1f4b6e; --border-strong: #2d6a96;
--text-primary: #eef9ff; --text-secondary: #b6e3f7; --text-muted: #7eb6d4; --text-body: #d5effc;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #67e8f9; --accent-purple-dim: rgba(103,232,249,.18);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 32px rgba(8,47,73,.55); --flk-theme-glow: rgba(34,211,238,.35);
}
html[data-theme="aurora"] {
--bg-root: #0a1020; --bg-surface: #121a32; --bg-elevated: #1a2550; --bg-hover: #243266;
--border-subtle: #3347a0; --border-strong: #4f6dff;
--text-primary: #f0f4ff; --text-secondary: #c7d2fe; --text-muted: #9db0f5; --text-body: #dde4ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #a78bfa; --accent-purple-dim: rgba(167,139,250,.22);
--accent-amber: #818cf8; --accent-amber-dim: rgba(129,140,248,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 8px 32px rgba(49,46,129,.5); --flk-theme-glow: rgba(167,139,250,.4);
}
html[data-theme="neon"] {
--bg-root: #050b14; --bg-surface: #0a1628; --bg-elevated: #10243f; --bg-hover: #163255;
--border-subtle: #1a4d6e; --border-strong: #06b6d4;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #06b6d4; --accent-cyan-dim: rgba(6,182,212,.28);
--accent-purple: #e879f9; --accent-purple-dim: rgba(232,121,249,.2);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.25);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #ff6bcb; --accent-red-dim: rgba(255,107,203,.22);
--shadow-panel: 0 0 40px rgba(6,182,212,.18); --flk-theme-glow: rgba(6,182,212,.45);
}
html[data-theme="glacier"] {
--bg-root: #0b1522; --bg-surface: #122033; --bg-elevated: #1a2f4a; --bg-hover: #223b5a;
--border-subtle: #2a5078; --border-strong: #3b82f6;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.22);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.2);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 28px rgba(30,64,175,.35); --flk-theme-glow: rgba(56,189,248,.35);
}
html[data-theme="cobalt"] {
--bg-root: #08101c; --bg-surface: #0f1a2e; --bg-elevated: #172642; --bg-hover: #1f3256;
--border-subtle: #274878; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.22);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.18);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 36px rgba(37,99,235,.28); --flk-theme-glow: rgba(59,130,246,.4);
}
/* ========== UI THEMES (6–15 new) ========== */
html[data-theme="reef"] {
--bg-root: #04161a; --bg-surface: #0a242b; --bg-elevated: #11353f; --bg-hover: #174650;
--border-subtle: #1f6b78; --border-strong: #2dd4bf;
--text-primary: #ecfeff; --text-secondary: #99f6e4; --text-muted: #5eead4; --text-body: #ccfbf1;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.2);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.2);
--shadow-panel: 0 10px 36px rgba(13,148,136,.35); --flk-theme-glow: rgba(45,212,191,.45);
}
html[data-theme="plasma"] {
--bg-root: #0c0618; --bg-surface: #180b2e; --bg-elevated: #26114a; --bg-hover: #321860;
--border-subtle: #6d28d9; --border-strong: #a78bfa;
--text-primary: #faf5ff; --text-secondary: #e9d5ff; --text-muted: #c4b5fd; --text-body: #f3e8ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.25);
--accent-amber: #f0abfc; --accent-amber-dim: rgba(240,171,252,.2);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(109,40,217,.4); --flk-theme-glow: rgba(192,132,252,.5);
}
html[data-theme="lagoon"] {
--bg-root: #031a1f; --bg-surface: #072830; --bg-elevated: #0c3a45; --bg-hover: #114c59;
--border-subtle: #0e7490; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.28);
--accent-purple: #2dd4bf; --accent-purple-dim: rgba(45,212,191,.22);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(8,145,178,.35); --flk-theme-glow: rgba(34,211,238,.45);
}
html[data-theme="indigo"] {
--bg-root: #07091a; --bg-surface: #0f1430; --bg-elevated: #1a2250; --bg-hover: #242d68;
--border-subtle: #3730a3; --border-strong: #6366f1;
--text-primary: #eef2ff; --text-secondary: #c7d2fe; --text-muted: #a5b4fc; --text-body: #e0e7ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.25);
--accent-amber: #60a5fa; --accent-amber-dim: rgba(96,165,250,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 10px 36px rgba(67,56,202,.4); --flk-theme-glow: rgba(99,102,241,.45);
}
html[data-theme="skyline"] {
--bg-root: #06101c; --bg-surface: #0c1c33; --bg-elevated: #142d4d; --bg-hover: #1b3c64;
--border-subtle: #0284c7; --border-strong: #38bdf8;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.28);
--accent-purple: #0ea5e9; --accent-purple-dim: rgba(14,165,233,.22);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.22);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(2,132,199,.35); --flk-theme-glow: rgba(56,189,248,.5);
}
html[data-theme="mintvolt"] {
--bg-root: #04140f; --bg-surface: #0a221a; --bg-elevated: #10352a; --bg-hover: #164738;
--border-subtle: #059669; --border-strong: #34d399;
--text-primary: #ecfdf5; --text-secondary: #a7f3d0; --text-muted: #6ee7b7; --text-body: #d1fae5;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #a3e635; --accent-amber-dim: rgba(163,230,53,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.25);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(5,150,105,.35); --flk-theme-glow: rgba(52,211,153,.45);
}
html[data-theme="sapphire"] {
--bg-root: #050d1f; --bg-surface: #0b1835; --bg-elevated: #122650; --bg-hover: #1a3470;
--border-subtle: #1d4ed8; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.25);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.22);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 40px rgba(29,78,216,.4); --flk-theme-glow: rgba(59,130,246,.5);
}
html[data-theme="arctic"] {
--bg-root: #0a121c; --bg-surface: #121c2a; --bg-elevated: #1c2a3d; --bg-hover: #26384f;
--border-subtle: #64748b; --border-strong: #94a3b8;
--text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-muted: #94a3b8; --text-body: #f1f5f9;
--accent-cyan: #67e8f9; --accent-cyan-dim: rgba(103,232,249,.25);
--accent-purple: #7dd3fc; --accent-purple-dim: rgba(125,211,252,.22);
--accent-amber: #bae6fd; --accent-amber-dim: rgba(186,230,253,.2);
--accent-green: #5eead4; --accent-green-dim: rgba(94,234,212,.2);
--accent-red: #fda4af; --accent-red-dim: rgba(253,164,175,.2);
--shadow-panel: 0 10px 32px rgba(100,116,139,.3); --flk-theme-glow: rgba(125,211,252,.4);
}
html[data-theme="hyperion"] {
--bg-root: #041018; --bg-surface: #081c2a; --bg-elevated: #0e2d42; --bg-hover: #143d58;
--border-subtle: #0891b2; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.3);
--accent-purple: #a3e635; --accent-purple-dim: rgba(163,230,53,.22);
--accent-amber: #4ade80; --accent-amber-dim: rgba(74,222,128,.22);
--accent-green: #84cc16; --accent-green-dim: rgba(132,204,22,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 0 42px rgba(8,145,178,.3); --flk-theme-glow: rgba(34,211,238,.55);
}
html[data-theme="meridian"] {
--bg-root: #0a0716; --bg-surface: #140d28; --bg-elevated: #221645; --bg-hover: #2e1e5c;
--border-subtle: #7c3aed; --border-strong: #a78bfa;
--text-primary: #f5f3ff; --text-secondary: #ddd6fe; --text-muted: #c4b5fd; --text-body: #ede9fe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.28);
--accent-amber: #e879f9; --accent-amber-dim: rgba(232,121,249,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(124,58,237,.4); --flk-theme-glow: rgba(167,139,250,.5);
}
html[data-theme] body {
background:
radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent-cyan) 18%, transparent), transparent 60%),
radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--accent-purple) 14%, transparent), transparent 55%),
var(--bg-root);
}
html[data-theme] .panel,
html[data-theme] .hm-neo {
border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle));
}
html[data-theme] .btn-pulse,
html[data-theme] .btn-primary {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 40%, transparent),
0 8px 24px color-mix(in srgb, var(--accent-cyan) 22%, transparent);
}
/* ========== SIDEBAR THEMES (independent, bright) ========== */
html[data-sidebar] .app-sidebar {
border-right: 1px solid color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 45%, transparent) !important;
box-shadow: inset -1px 0 0 color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 20%, transparent),
4px 0 28px color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 12%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill:hover {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 16%, transparent) !important;
border-color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 35%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill.active {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 22%, rgba(8,12,18,.9)) !important;
box-shadow: inset 3px 0 0 var(--flk-sidebar-accent, #22d3ee);
}
html[data-sidebar] .app-sidebar-label {
color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 85%, #fff) !important;
}
html[data-sidebar="aqua"] .app-sidebar {
--flk-sidebar-accent: #5eead4;
--flk-sidebar-solid: #0a4f5c;
--flk-sidebar-grad: linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="azure"] .app-sidebar {
--flk-sidebar-accent: #60a5fa;
--flk-sidebar-solid: #163a7a;
--flk-sidebar-grad: linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="turquoise"] .app-sidebar {
--flk-sidebar-accent: #2dd4bf;
--flk-sidebar-solid: #0a453f;
--flk-sidebar-grad: linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="cerulean"] .app-sidebar {
--flk-sidebar-accent: #38bdf8;
--flk-sidebar-solid: #0c4a6e;
--flk-sidebar-grad: linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="electric"] .app-sidebar {
--flk-sidebar-accent: #22d3ee;
--flk-sidebar-solid: #0a3d4a;
--flk-sidebar-grad: linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="violet"] .app-sidebar {
--flk-sidebar-accent: #818cf8;
--flk-sidebar-solid: #2a1f6b;
--flk-sidebar-grad: linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="emerald"] .app-sidebar {
--flk-sidebar-accent: #34d399;
--flk-sidebar-solid: #0a4a38;
--flk-sidebar-grad: linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="midnight"] .app-sidebar {
--flk-sidebar-accent: #7dd3fc;
--flk-sidebar-solid: #0d1f3c;
--flk-sidebar-grad: linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="steel"] .app-sidebar {
--flk-sidebar-accent: #94a3b8;
--flk-sidebar-solid: #1a2d42;
--flk-sidebar-grad: linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="nordic"] .app-sidebar {
--flk-sidebar-accent: #67e8f9;
--flk-sidebar-solid: #0e3a4f;
--flk-sidebar-grad: linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%);
background: var(--flk-sidebar-grad) !important;
}
/* Sidebar fill mode: gradient (default) vs plain solid */
html[data-sidebar][data-sidebar-style="plain"] .app-sidebar {
background: var(--flk-sidebar-solid, #0a3d4a) !important;
}
html[data-sidebar]:not([data-sidebar-style="plain"]) .app-sidebar {
background: var(--flk-sidebar-grad) !important;
}
.flk-style-toggle {
display: inline-flex;
gap: 0.35rem;
margin: 0.65rem 0 0.85rem;
padding: 0.25rem;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent);
}
.flk-style-toggle button {
border: 0;
background: transparent;
color: var(--text-secondary, #b6e3f7);
padding: 0.4rem 0.85rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.flk-style-toggle button.is-active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent);
color: #fff;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan, #22d3ee) 45%, transparent);
}
/* Settings cards */
.flk-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
margin-top: 0.85rem;
}
.flk-theme-card {
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 0.75rem;
background: var(--bg-elevated);
cursor: pointer;
text-align: left;
color: inherit;
transition: border-color .15s, transform .15s, box-shadow .15s;
}
.flk-theme-card:hover { transform: translateY(-2px); border-color: var(--accent-cyan); }
.flk-theme-card.is-active {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim), 0 12px 28px rgba(0,0,0,.35);
}
.flk-theme-swatch {
height: 52px; border-radius: 10px; margin-bottom: 0.55rem;
border: 1px solid rgba(255,255,255,.18);
background-color: #1e293b;
background-size: cover !important;
background-repeat: no-repeat !important;
}
.flk-theme-card strong { display: block; margin-bottom: 0.12rem; font-size: 0.9rem; }
.flk-theme-card small { opacity: 0.75; line-height: 1.3; display: block; font-size: 0.75rem; }
.flk-theme-section { margin-top: 1.75rem; }
.flk-theme-section h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
/* ========== PAGE BACKGROUNDS (independent of UI theme) ==========
* <html data-bg="..."> localStorage: flk-page-bg
* Applies to body + app-main + common subpage shells
*/
html[data-bg="navy"] { --flk-page-bg: #0d2b5c; --flk-page-bg2: #134078; --bg-root: #0d2b5c; }
html[data-bg="royal"] { --flk-page-bg: #1a3a8f; --flk-page-bg2: #274bb8; --bg-root: #1a3a8f; }
html[data-bg="azure"] { --flk-page-bg: #0a4f8a; --flk-page-bg2: #0d6cb8; --bg-root: #0a4f8a; }
html[data-bg="sky"] { --flk-page-bg: #0e5f9e; --flk-page-bg2: #1880c9; --bg-root: #0e5f9e; }
html[data-bg="cerulean"] { --flk-page-bg: #0c6a9e; --flk-page-bg2: #1490c8; --bg-root: #0c6a9e; }
html[data-bg="teal"] { --flk-page-bg: #0c5c68; --flk-page-bg2: #128090; --bg-root: #0c5c68; }
html[data-bg="lagoon"] { --flk-page-bg: #0a6b7a; --flk-page-bg2: #0f8fa3; --bg-root: #0a6b7a; }
html[data-bg="cyan"] { --flk-page-bg: #0a5568; --flk-page-bg2: #0e7a94; --bg-root: #0a5568; }
html[data-bg="indigo"] { --flk-page-bg: #2f2a7a; --flk-page-bg2: #4338ca; --bg-root: #2f2a7a; }
html[data-bg="violet"] { --flk-page-bg: #4a1d7a; --flk-page-bg2: #6d28d9; --bg-root: #4a1d7a; }
html[data-bg="sapphire"] { --flk-page-bg: #123a8c; --flk-page-bg2: #1d4ed8; --bg-root: #123a8c; }
html[data-bg="midnight"] { --flk-page-bg: #1a2f55; --flk-page-bg2: #274878; --bg-root: #1a2f55; }
html[data-bg="emerald"] { --flk-page-bg: #0d5c45; --flk-page-bg2: #059669; --bg-root: #0d5c45; }
html[data-bg="slate"] { --flk-page-bg: #243447; --flk-page-bg2: #334155; --bg-root: #243447; }
html[data-bg="graphite"] { --flk-page-bg: #1e2a3a; --flk-page-bg2: #2c3b52; --bg-root: #1e2a3a; }
html[data-bg] body {
background:
radial-gradient(ellipse 90% 55% at 12% -8%, color-mix(in srgb, var(--accent-cyan, #22d3ee) 18%, transparent), transparent 55%),
radial-gradient(ellipse 70% 45% at 92% 8%, color-mix(in srgb, var(--accent-purple, #818cf8) 14%, transparent), transparent 50%),
linear-gradient(165deg, var(--flk-page-bg) 0%, var(--flk-page-bg2, var(--flk-page-bg)) 100%) !important;
background-attachment: fixed !important;
}
html[data-bg] .app-main,
html[data-bg] .app-shell,
html[data-bg] main,
html[data-bg] .page,
html[data-bg] .page-wrap,
html[data-bg] .content-area,
html[data-bg] .settings-layout,
html[data-bg] .hm-page,
html[data-bg] .warroom-root,
html[data-bg] .agents-page {
background: transparent !important;
}
html[data-bg] .panel,
html[data-bg] .hm-neo,
html[data-bg] .card,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Kill leftover near-black shells when page bg is set */
html[data-bg] .app-main > *,
html[data-bg] .dashboard-root,
html[data-bg] .retail-page,
html[data-bg] .ops-page,
html[data-bg] .documents-page,
html[data-bg] .analytics-wrap,
html[data-bg] .export-intel,
html[data-bg] .clients-page,
html[data-bg] .wr-shell,
html[data-bg] .voice-page,
html[data-bg] .studio-page,
html[data-bg] .hermes-page,
html[data-bg] .projects-hub,
html[data-bg] .packaging-page,
html[data-bg] .beurs-page,
html[data-bg] .marketing-page,
html[data-bg] .revenue-cockpit {
background-color: transparent !important;
}
html[data-bg] {
background: var(--flk-page-bg) !important;
}
/* ========== SURFACE OVERRIDE (kill leftover blacks on subpages) ========== */
html[data-theme],
html[data-bg] {
--flk-surface: color-mix(in srgb, var(--bg-elevated, #123049) 88%, #fff 0%);
--flk-surface-2: color-mix(in srgb, var(--bg-surface, #0c1c2e) 92%, var(--accent-cyan, #22d3ee) 8%);
--flk-input: color-mix(in srgb, var(--bg-root, #07131f) 70%, var(--bg-elevated, #123049) 30%);
--flk-panel: color-mix(in srgb, var(--bg-elevated, #123049) 75%, transparent);
}
/* When a page background is chosen, lift surface tokens off near-black */
html[data-bg="navy"] { --bg-surface: #134078; --bg-elevated: #1a4f96; --bg-hover: #2460b0; }
html[data-bg="royal"] { --bg-surface: #274bb8; --bg-elevated: #3359d4; --bg-hover: #4169e8; }
html[data-bg="azure"] { --bg-surface: #0d6cb8; --bg-elevated: #1580d4; --bg-hover: #1e92ea; }
html[data-bg="sky"] { --bg-surface: #1880c9; --bg-elevated: #2493de; --bg-hover: #36a4ec; }
html[data-bg="cerulean"] { --bg-surface: #1490c8; --bg-elevated: #20a3dc; --bg-hover: #32b4ea; }
html[data-bg="teal"] { --bg-surface: #128090; --bg-elevated: #1898a8; --bg-hover: #22aebb; }
html[data-bg="lagoon"] { --bg-surface: #0f8fa3; --bg-elevated: #18a4ba; --bg-hover: #24b8ce; }
html[data-bg="cyan"] { --bg-surface: #0e7a94; --bg-elevated: #1490ae; --bg-hover: #1ea4c4; }
html[data-bg="indigo"] { --bg-surface: #4338ca; --bg-elevated: #5450e0; --bg-hover: #6666f0; }
html[data-bg="violet"] { --bg-surface: #6d28d9; --bg-elevated: #7e3aec; --bg-hover: #8f4cf8; }
html[data-bg="sapphire"] { --bg-surface: #1d4ed8; --bg-elevated: #2b5fec; --bg-hover: #3b70f8; }
html[data-bg="midnight"] { --bg-surface: #274878; --bg-elevated: #335c96; --bg-hover: #4070b0; }
html[data-bg="emerald"] { --bg-surface: #059669; --bg-elevated: #0aad78; --bg-hover: #14c08a; }
html[data-bg="slate"] { --bg-surface: #334155; --bg-elevated: #3f5068; --bg-hover: #4d6078; }
html[data-bg="graphite"] { --bg-surface: #2c3b52; --bg-elevated: #384a66; --bg-hover: #465a78; }
/* App chrome */
html[data-theme] .app-topbar,
html[data-bg] .app-topbar,
html[data-theme] .app-main,
html[data-bg] .app-main {
background: transparent !important;
}
html[data-theme] .app-topbar,
html[data-bg] .app-topbar {
background: color-mix(in srgb, var(--bg-elevated, #123049) 55%, transparent) !important;
border-bottom-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
backdrop-filter: blur(10px);
}
/* Generic panels / cards */
html[data-theme] .panel,
html[data-bg] .panel,
html[data-theme] .card,
html[data-bg] .card,
html[data-theme] .hm-neo,
html[data-bg] .hm-neo,
html[data-theme] .settings-panel,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 78%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--border-subtle, #1f4b6e)) !important;
}
/* ========== Export Intel surfaces ========== */
html[data-theme] .ei-page,
html[data-bg] .ei-page {
background: transparent !important;
}
html[data-theme] .ei-kpi,
html[data-bg] .ei-kpi,
html[data-theme] .ei-neo-kpi,
html[data-bg] .ei-neo-kpi {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-neo-ring-inner,
html[data-bg] .ei-neo-ring-inner {
background: var(--bg-surface, #0c1c2e) !important;
}
html[data-theme] .ei-filter-bar,
html[data-bg] .ei-filter-bar {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent-cyan, #22d3ee) 16%, var(--bg-elevated, #123049)),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 90%, transparent)
) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-filters select,
html[data-theme] .ei-filters input,
html[data-theme] .ei-filter-label select,
html[data-theme] .ei-filter-label input,
html[data-bg] .ei-filters select,
html[data-bg] .ei-filters input,
html[data-bg] .ei-filter-label select,
html[data-bg] .ei-filter-label input {
background: color-mix(in srgb, var(--bg-root, #07131f) 55%, var(--bg-elevated, #123049) 45%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
color: var(--text-primary, #eef9ff) !important;
}
html[data-theme] .ei-tab-panel,
html[data-bg] .ei-tab-panel,
html[data-theme] .ei-tab-panel:has(.ei-workspace-body),
html[data-bg] .ei-tab-panel:has(.ei-workspace-body) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 72%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent) !important;
}
html[data-theme] .ei-side-panel,
html[data-bg] .ei-side-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-elevated, #123049) 92%, #fff 0%),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 95%, transparent)
) !important;
border-left-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
}
html[data-theme] .ei-side-panel-head,
html[data-bg] .ei-side-panel-head {
background: color-mix(in srgb, var(--bg-hover, #183a58) 55%, transparent) !important;
}
html[data-theme] .ei-map-wrap,
html[data-bg] .ei-map-wrap {
background: color-mix(in srgb, var(--bg-root, #07131f) 80%, var(--bg-elevated, #123049) 20%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-map-loading,
html[data-bg] .ei-map-loading {
background: color-mix(in srgb, var(--bg-root, #07131f) 70%, transparent) !important;
}
html[data-theme] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-theme] .ei-map-wrap .leaflet-popup-tip,
html[data-bg] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-bg] .ei-map-wrap .leaflet-popup-tip {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 40%, transparent) !important;
}
html[data-theme] .ei-table th,
html[data-bg] .ei-table th {
background: var(--bg-elevated, #123049) !important;
}
html[data-theme] .ei-table tbody tr,
html[data-bg] .ei-table tbody tr {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent);
}
html[data-theme] .ei-table tbody tr:nth-child(even),
html[data-bg] .ei-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 45%, transparent);
}
html[data-theme] .ei-drawer,
html[data-bg] .ei-drawer,
html[data-theme] .ei-modal-card,
html[data-bg] .ei-modal-card,
html[data-theme] .ei-progress-card,
html[data-bg] .ei-progress-card {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-monitor,
html[data-bg] .ei-sync-monitor {
background: color-mix(in srgb, var(--bg-elevated, #123049) 88%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-job,
html[data-bg] .ei-sync-job,
html[data-theme] .ei-sync-coverage div,
html[data-bg] .ei-sync-coverage div,
html[data-theme] .ei-google-places,
html[data-bg] .ei-google-places {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 70%, transparent) !important;
}
html[data-theme] .wereldexport-workspace > .vtabs-nav,
html[data-bg] .wereldexport-workspace > .vtabs-nav {
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent) !important;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent);
}
html[data-theme] .vtab-btn,
html[data-bg] .vtab-btn {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent) !important;
}
html[data-theme] .vtab-btn.active,
html[data-bg] .vtab-btn.active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--bg-elevated, #123049)) !important;
}
/* Persona / brand switch in topbar */
html[data-theme] .persona-btn,
html[data-bg] .persona-btn,
html[data-theme] .brand-switch .persona-btn,
html[data-bg] .brand-switch .persona-btn {
background: color-mix(in srgb, var(--bg-elevated, #123049) 60%, transparent) !important;
}
+304
View File
@@ -0,0 +1,304 @@
/* Herman / Hermes chat — always-on floating dock */
.ha-dock {
/* wrapper only; FAB/panel are fixed to viewport */
}
.ha-dock-hidden {
display: none !important;
}
.ha-fab {
position: fixed !important;
right: max(1.25rem, env(safe-area-inset-right, 0px));
bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
z-index: 10050 !important;
height: 52px;
width: auto;
min-width: 52px;
padding: 0 1.15rem 0 0.9rem;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.65);
background: linear-gradient(145deg, #0ea5e9 0%, #0284c7 55%, #0369a1 100%);
color: #f8fafc;
font-size: 1.15rem;
cursor: pointer;
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0.45rem;
box-shadow:
0 0 0 4px rgba(14, 165, 233, 0.18),
0 10px 28px rgba(0, 0, 0, 0.5);
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
pointer-events: auto;
}
.ha-fab-icon { line-height: 1; font-size: 1.2rem; }
.ha-fab-label {
font-size: 0.88rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: none;
}
.ha-fab:hover {
transform: translateY(-2px) scale(1.03);
box-shadow:
0 0 0 5px rgba(14, 165, 233, 0.28),
0 14px 34px rgba(0, 0, 0, 0.55);
}
.ha-fab.is-open {
background: linear-gradient(145deg, #4c1d95, #1e1b4b);
border-color: rgba(167, 139, 250, 0.7);
box-shadow:
0 0 0 4px rgba(167, 139, 250, 0.2),
0 10px 28px rgba(0, 0, 0, 0.5);
}
.ha-fab.is-busy {
border-color: #38bdf8;
animation: ha-pulse 1.2s infinite;
}
.ha-panel {
position: fixed !important;
right: max(1rem, env(safe-area-inset-right, 0px));
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
z-index: 10049 !important;
width: min(420px, calc(100vw - 2rem));
max-height: min(620px, calc(100vh - 8rem));
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(10, 14, 20, 0.97);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.ha-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(167, 139, 250, 0.08);
gap: 0.5rem;
}
.ha-head-title { min-width: 0; }
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; display: flex; align-items: center; gap: 0.4rem; }
.ha-badge {
font-size: 0.62rem;
font-weight: 600;
color: #ddd6fe;
background: rgba(124, 58, 237, 0.35);
border: 1px solid rgba(167, 139, 250, 0.45);
border-radius: 999px;
padding: 0.12rem 0.45rem;
letter-spacing: 0.02em;
}
.ha-conn {
margin: 0.15rem 0 0;
font-size: 0.62rem;
color: #64748b;
}
.ha-conn.is-ok { color: #34d399; }
.ha-conn.is-err { color: #f87171; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; flex-shrink: 0; }
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
.ha-text-btn { font-size: 0.68rem !important; color: #94a3b8 !important; padding: 0.2rem 0.35rem !important; }
.ha-text-btn:hover { color: #e2e8f0 !important; }
.ha-text-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ha-confirm {
padding: 0.5rem 0.75rem;
background: rgba(251, 191, 36, 0.1);
border-bottom: 1px solid rgba(251, 191, 36, 0.25);
font-size: 0.75rem;
color: #fde68a;
}
.ha-confirm-actions { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
.ha-log {
flex: 1;
overflow-y: auto;
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 220px;
max-height: 360px;
}
.ha-bubble {
max-width: 92%;
padding: 0.5rem 0.7rem;
border-radius: 10px;
font-size: 0.8rem;
line-height: 1.4;
}
.ha-bubble-user {
align-self: flex-end;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.28);
}
.ha-bubble-agent {
align-self: flex-start;
background: rgba(167, 139, 250, 0.12);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.ha-bubble-meta { font-size: 0.62rem; color: #64748b; margin-bottom: 0.15rem; }
.ha-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.ha-preview {
margin: 0 0.65rem;
padding: 0.4rem 0.55rem;
border-radius: 8px;
font-size: 0.72rem;
color: #94a3b8;
border: 1px dashed rgba(167, 139, 250, 0.3);
min-height: 1.5rem;
}
.ha-preview.is-interim { font-style: italic; }
.ha-input-row {
display: flex;
gap: 0.35rem;
padding: 0.55rem 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
align-items: center;
margin: 0;
}
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
.ha-mic {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(167, 139, 250, 0.45);
background: rgba(15, 20, 25, 0.95);
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
}
.ha-mic.is-listening { border-color: #f87171; animation: ha-pulse 1s infinite; }
.ha-mic.is-busy { border-color: #38bdf8; }
@keyframes ha-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15); }
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); }
}
.ha-status { font-size: 0.72rem; color: #cbd5e1; padding: 0.25rem 0.65rem 0.4rem; line-height: 1.35; }
.ha-status.is-error { color: #fecaca; background: rgba(127, 29, 29, 0.35); border-radius: 6px; margin: 0 0.5rem 0.35rem; padding: 0.4rem 0.55rem; }
.ha-secure-hint {
margin: 0.25rem 0.55rem 0.45rem;
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.45);
color: #e2e8f0;
font-size: 0.78rem;
line-height: 1.4;
}
.ha-secure-hint strong { color: #f8fafc; display: block; margin-bottom: 0.25rem; }
.ha-secure-hint a { display: inline-block; margin-top: 0.4rem; }
.ha-results-modal { z-index: 1600; }
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
.ei-modal-root {
position: fixed; inset: 0; z-index: 1600;
display: flex; align-items: center; justify-content: center;
padding: 1rem; background: rgba(0, 0, 0, 0.55);
}
.ei-modal-card {
background: #0f1419; border-radius: 14px; padding: 1.25rem;
border: 1px solid rgba(14, 165, 233, 0.25);
max-width: 96vw; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
.voice-results-table th, .voice-results-table td { padding: 0.4rem 0.55rem; border-top: 1px solid rgba(148,163,184,0.1); font-size: 0.78rem; }
.voice-results-table a { color: #38bdf8; }
@media (max-width: 480px) {
.ha-fab {
right: 1rem;
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); /* above mobile bottom nav */
}
.ha-panel {
right: 0.5rem;
left: 0.5rem;
width: auto;
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
}
}
.ha-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.9rem;
padding: 0.15rem 0.65rem 0.35rem;
font-size: 0.68rem;
color: #94a3b8;
}
.ha-toggles label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
user-select: none;
}
.ha-toggles input {
accent-color: #38bdf8;
margin: 0;
}
.ha-mic.is-listening {
box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.35);
}
/* Toasts boven de Herman FAB, niet erachter */
.toast-container {
bottom: 5.75rem !important;
right: 1rem !important;
left: auto !important;
z-index: 10120 !important;
max-width: min(360px, calc(100vw - 2rem));
}
.toast {
color: #f1f5f9 !important;
background: #0f172a !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.toast-error { border-color: #f87171 !important; color: #fecaca !important; }
.ha-https-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0.55rem 0.4rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.4);
color: #e2e8f0;
font-size: 0.75rem;
}
.ha-https-prompt .btn { flex-shrink: 0; }
.ha-https-prompt .ha-text-btn { margin-left: auto; color: #94a3b8; }
.ha-fab.has-custom-pos {
right: auto !important;
bottom: auto !important;
}
.ha-fab.is-dragging {
cursor: grabbing !important;
transition: none !important;
transform: none !important;
user-select: none;
touch-action: none;
}
.ha-fab {
touch-action: none;
user-select: none;
}
.ha-panel.has-custom-pos {
right: auto !important;
bottom: auto !important;
}
+848 -17
View File
@@ -1,6 +1,174 @@
/* Herman dashboard — Hermes neo style (KPI cards animate, grote panelen vast) */
.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); }
.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); --hm-radius: 16px; --hm-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); }
.herman-shell.hm-neo { scroll-behavior: auto; }
.hm-hero-pro {
padding: 1.35rem 1.5rem 1.1rem;
box-shadow: var(--hm-shadow);
}
.hm-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.85rem;
}
.hm-hero-eyebrow {
margin: 0 0 0.35rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.hm-hero-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 229, 255, 0.18);
font-size: 0.72rem;
color: #cbd5e1;
white-space: nowrap;
}
.hm-live-badge {
font-size: 0.62rem;
vertical-align: middle;
margin-left: 0.35rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.35);
color: #86efac;
letter-spacing: 0.04em;
}
.hm-command-bar {
position: sticky;
top: 0.5rem;
z-index: 30;
margin-bottom: 1rem;
padding: 0.85rem 1rem;
border-radius: var(--hm-radius);
background: rgba(8, 12, 18, 0.92);
border: 1px solid var(--hm-border);
backdrop-filter: blur(12px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
}
.briefing-meta {
margin-top: 0.25rem;
font-size: 0.72rem;
color: #94a3b8;
letter-spacing: 0.02em;
}
.hm-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.hm-card-head h4 { margin: 0; }
.hm-card-link {
font-size: 0.68rem;
color: #38bdf8;
text-decoration: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.hm-card-link:hover { color: #7dd3fc; }
.hm-activity-card { border: 1px solid rgba(148, 163, 184, 0.12); }
.hm-activity-timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.hm-activity-item {
display: grid;
grid-template-columns: 10px 1fr;
gap: 0.65rem;
align-items: start;
padding: 0.45rem 0.55rem;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.hm-activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 0.35rem;
background: var(--hm-cyan);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.45);
}
.hm-activity-text {
font-size: 0.78rem;
line-height: 1.45;
color: #dbeafe;
}
.hm-activity-empty {
font-size: 0.78rem;
color: #64748b;
padding: 0.35rem 0;
}
.hm-live-dock {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
width: min(420px, calc(100vw - 2rem));
z-index: 120;
padding: 0.85rem 1rem;
background: rgba(8, 12, 18, 0.97);
border: 1px solid rgba(0, 229, 255, 0.35);
border-radius: 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55), 0 0 24px rgba(0, 229, 255, 0.1);
backdrop-filter: blur(14px);
animation: hmDockIn 0.28s ease-out;
}
@keyframes hmDockIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.hm-live-dock-active { border-color: var(--hm-cyan); }
.hm-live-dock-done { border-color: rgba(34, 197, 94, 0.45); }
.hm-live-dock-head {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
}
.hm-live-log-compact {
max-height: min(36vh, 280px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0, 229, 255, 0.35) transparent;
}
.hm-live-log-compact::-webkit-scrollbar { width: 6px; }
.hm-live-log-compact::-webkit-scrollbar-thumb {
background: rgba(0, 229, 255, 0.28);
border-radius: 999px;
}
@media (max-width: 720px) {
.hm-live-dock {
right: 0.75rem;
left: 0.75rem;
width: auto;
}
.hm-command-bar { position: relative; top: auto; }
}
.herman-shell.hm-neo .herman-hero {
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
@@ -34,14 +202,91 @@
@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } }
.briefing-summary-grid {
display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem;
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;
}
.briefing-card.summary { grid-column: 1 / -1; }
.briefing-horizon-col { display: grid; gap: 1rem; }
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
.live-digest-body {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (max-width: 1100px) { .live-digest-body { grid-template-columns: 1fr; } }
.live-digest-section {
background: rgba(0, 0, 0, 0.22);
border-radius: 10px;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.live-digest-section.brand-foodlinkk { border-left: 3px solid #fbbf24; }
.live-digest-section.brand-cucina { border-left: 3px solid #fb923c; }
.live-digest-section.brand-platform { border-left: 3px solid var(--hm-cyan); }
.live-digest-section.brand-news { border-left: 3px solid #a855f7; }
.live-digest-section h5 {
margin: 0 0 0.45rem;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
}
.live-digest-section.brand-foodlinkk h5 { color: #fbbf24; }
.live-digest-section.brand-cucina h5 { color: #fdba74; }
.live-digest-section.brand-platform h5 { color: var(--hm-cyan); }
.live-digest-section.brand-news h5 { color: #c4b5fd; }
.live-digest-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.live-digest-list li {
font-size: 0.82rem;
line-height: 1.45;
color: #e2e8f0;
padding-left: 0.65rem;
border-left: 2px solid rgba(255, 255, 255, 0.08);
}
.live-digest-list li.is-typing::after {
content: '▋';
display: inline-block;
margin-left: 1px;
color: var(--hm-cyan);
animation: twCursorBlink 0.85s step-end infinite;
font-weight: 400;
vertical-align: baseline;
}
.live-digest-body.is-typing .live-digest-section {
animation: twSectionIn 0.35s ease-out both;
}
@keyframes twCursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes twSectionIn {
from { opacity: 0.4; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.horizon-card ul li.is-typing::after {
content: '▋';
color: #ff9f43;
animation: twCursorBlink 0.85s step-end infinite;
margin-left: 1px;
}
.briefing-card.summary,
.horizon-card {
background: var(--hm-panel); border-radius: 14px; padding: 1rem;
.horizon-card,
.briefing-card.hm-activity-card {
background: var(--hm-panel); border-radius: var(--hm-radius); padding: 1rem 1.05rem;
border: 1px solid rgba(148, 163, 184, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.briefing-card.summary { border: 1px solid var(--hm-border); }
.briefing-card.summary h4,
@@ -163,7 +408,8 @@
.hm-chart-panel {
background: var(--hm-panel); border: 1px solid var(--hm-border);
border-radius: 14px; padding: 1rem 1.1rem; min-height: 260px;
border-radius: var(--hm-radius); padding: 1rem 1.1rem; min-height: 260px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.hm-chart-head {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;
@@ -202,16 +448,7 @@
.page-viz-panel.viz-mode-alt .viz-eq-row { font-size: 0.82rem; padding: 0.15rem 0; }
.hm-live-bar {
position: sticky;
top: 0;
z-index: 40;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: rgba(8, 12, 18, 0.97);
border: 1px solid rgba(0, 229, 255, 0.35);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 20px rgba(0, 229, 255, 0.08);
backdrop-filter: blur(8px);
display: none;
}
.hm-live-bar-active { border-color: var(--hm-cyan); animation: hmLivePulse 2s ease-in-out infinite; }
.hm-live-bar-done { border-color: rgba(34, 197, 94, 0.45); }
@@ -435,9 +672,17 @@
.app-topbar .page-tip { display: none; }
}
.hm-highlight-scroll { max-height: 220px; overflow-y: auto; }
.hm-highlight-scroll,
.hm-highlight-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.65rem;
}
.hm-highlight-item {
padding: 0.55rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
padding: 0.75rem 0.85rem;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hm-highlight-item strong { color: #e2e8f0; font-size: 0.85rem; text-decoration: none; }
.hm-highlight-item a { color: inherit; text-decoration: none; }
@@ -558,3 +803,589 @@
display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem;
}
.agent-soul-editor .agent-editor-header h2 { margin: 0; }
/* Herman dashboard tabs */
.herman-dash-tabs {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin: 0 0 1rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.88);
border: 1px solid rgba(0, 229, 255, 0.14);
border-radius: 14px;
}
.herman-dash-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
transition: all 0.18s;
}
.herman-dash-tab:hover { color: #e2e8f0; background: rgba(0, 229, 255, 0.06); }
.herman-dash-tab.active {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.1));
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 18px rgba(0, 229, 255, 0.08);
}
.herman-dash-tab-badge {
font-size: 0.62rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(0, 229, 255, 0.15);
color: #7dd3fc;
}
.hm-rss-teaser {
font-size: 0.8rem;
color: #94a3b8;
margin: 0;
}
.hm-rss-teaser a { color: #38bdf8; }
/* RSS hub tab */
.herman-rss-hub { margin-top: 0.25rem; }
.herman-rss-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
}
.herman-rss-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
.herman-rss-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.herman-rss-filters {
display: grid;
grid-template-columns: 1fr 200px auto;
gap: 0.55rem;
align-items: center;
margin-bottom: 1rem;
}
@media (max-width: 720px) { .herman-rss-filters { grid-template-columns: 1fr; } }
.herman-rss-count { font-size: 0.72rem; color: #94a3b8; white-space: nowrap; }
.herman-rss-grid {
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 1rem;
align-items: start;
}
@media (max-width: 1000px) { .herman-rss-grid { grid-template-columns: 1fr; } }
.hm-rss-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.herman-rss-card { margin: 0; }
.herman-rss-side-item {
padding: 0.45rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
font-size: 0.78rem;
}
.herman-rss-side-item a { color: #e2e8f0; text-decoration: none; display: block; margin-bottom: 0.15rem; }
.herman-rss-side-item a:hover { color: #7dd3fc; }
.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;
}
+159
View File
@@ -216,3 +216,162 @@
.dot-in { background: var(--hm-cyan); }
.dot-out { background: var(--hm-purple); }
.dot-edge { background: var(--hm-orange); }
.hm-section-title {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
margin: 1.25rem 0 0.75rem;
}
.hm-bucket-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.hm-bucket-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 12px;
padding: 0.85rem 0.9rem;
}
.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; }
.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; }
.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
.hm-tool-list li {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
font-size: 0.78rem; color: #cbd5e1;
}
.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; }
.hm-tool-list-tall { max-height: 280px; overflow: auto; }
.status-badge.on { color: #b8ff3c; }
.status-badge.off { color: #f87171; }
/* Clickable buckets + explorer modal */
.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; }
.hm-bucket-click {
display: block; width: 100%; text-align: left; cursor: pointer;
transition: border-color .15s, transform .12s, background .15s;
font: inherit; color: inherit;
}
.hm-bucket-click:hover {
border-color: rgba(0, 229, 255, 0.55);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.hm-bucket-open {
margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75;
}
.hm-bucket-click:hover .hm-bucket-open { opacity: 1; }
.hm-modal-backdrop {
position: fixed; inset: 0; z-index: 80;
background: rgba(2, 6, 14, 0.72);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.hm-modal {
width: min(1100px, 96vw);
height: min(820px, 92vh);
background: #0b1220;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 14px;
box-shadow: 0 24px 80px rgba(0,0,0,0.55);
display: flex; flex-direction: column;
overflow: hidden;
}
.hm-modal-head {
display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start;
padding: 1rem 1.1rem 0.75rem;
border-bottom: 1px solid rgba(148,163,184,0.12);
}
.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.hm-modal-close {
background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem;
}
.hm-modal-close:hover { color: #fff; }
.hm-chip {
border: 1px solid rgba(148,163,184,0.25);
background: rgba(15,23,42,0.7);
color: #cbd5e1; border-radius: 999px;
padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer;
}
.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); }
.hm-modal-tabs {
display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0;
border-bottom: 1px solid rgba(148,163,184,0.1);
}
.hm-modal-tabs button {
background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem;
font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent;
}
.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; }
.hm-modal-body { flex: 1; min-height: 0; }
.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; }
.hm-modal-side {
border-right: 1px solid rgba(148,163,184,0.12);
overflow: auto; background: rgba(8, 12, 20, 0.9);
position: relative;
}
.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; }
.hm-side-loading {
position: sticky; top: 0; z-index: 2;
padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8;
background: rgba(14,165,233,0.08);
}
.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; }
.hm-event-item, .hm-file-item {
width: 100%; text-align: left; cursor: pointer;
background: transparent; border: 1px solid transparent; border-radius: 8px;
padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem;
}
.hm-event-item:hover, .hm-file-item:hover,
.hm-event-item.active, .hm-file-item.active {
background: rgba(14,165,233,0.1);
border-color: rgba(56,189,248,0.25);
}
.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; }
.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; }
.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; }
.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; }
.hm-event-preview, .hm-file-path {
font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hm-file-name { display: flex; gap: 0.4rem; align-items: center; }
.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; }
.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; }
.hm-explorer-bar {
display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;
padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1);
position: sticky; top: 0; background: #0b1220; z-index: 1;
}
.hm-explorer-path {
font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; }
.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; }
.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; }
.hm-preview-code {
margin: 0; padding: 0.85rem; border-radius: 10px;
background: #020617; border: 1px solid rgba(148,163,184,0.15);
color: #e2e8f0; font-size: 0.72rem; line-height: 1.45;
white-space: pre-wrap; word-break: break-word; max-height: none;
}
.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; }
.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; }
@media (max-width: 820px) {
.hm-modal { height: 94vh; }
.hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; }
.hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); }
}
+99
View File
@@ -0,0 +1,99 @@
:root {
--bg0: #06090e;
--bg1: #0c121b;
--card: #111821;
--line: rgba(148, 163, 184, 0.18);
--text: #f1f5f9;
--muted: #94a3b8;
--gold: #fbbf24;
--green: #22c55e;
--danger: #f87171;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
.login-body {
min-height: 100%;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 15% -10%, rgba(251, 191, 36, 0.14), transparent 55%),
radial-gradient(700px 380px at 100% 10%, rgba(34, 197, 94, 0.1), transparent 50%),
linear-gradient(160deg, var(--bg0), var(--bg1) 55%, #0a1018);
}
.login-shell {
min-height: 100%;
display: grid;
place-items: center;
padding: max(1.25rem, env(safe-area-inset-top)) 1rem max(1.25rem, env(safe-area-inset-bottom));
}
.login-card {
width: min(420px, 100%);
background: rgba(17, 24, 33, 0.92);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.75rem 1.5rem 1.35rem;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
.login-logo {
display: block;
width: min(280px, 100%);
height: auto;
margin: 0 auto 0.75rem;
}
.login-sub {
margin: 0 0 1.25rem;
text-align: center;
color: var(--muted);
font-size: 0.92rem;
}
.login-error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
border-radius: 10px;
padding: 0.65rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.88rem;
}
.login-form { display: grid; gap: 0.85rem; }
.login-label {
display: grid;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.02em;
}
.login-input {
width: 100%;
min-height: 46px;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
padding: 0.65rem 0.8rem;
font-size: 16px;
}
.login-input:focus {
outline: none;
border-color: rgba(251, 191, 36, 0.55);
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.12);
}
.login-btn {
margin-top: 0.35rem;
min-height: 48px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: #111827;
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
.login-btn:hover { filter: brightness(1.05); }
.login-foot {
margin: 1.1rem 0 0;
text-align: center;
color: #64748b;
font-size: 0.75rem;
}
+324 -24
View File
@@ -1,5 +1,6 @@
/**
* Foodlinkk Command Center — mobile (Android + iOS)
* Must load LAST so it wins over app-sidebar-neo / herman-dashboard.
*/
:root {
--safe-top: env(safe-area-inset-top, 0px);
@@ -32,8 +33,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 998;
backdrop-filter: blur(2px);
z-index: 999;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@media (max-width: 1024px) {
@@ -68,16 +70,6 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
}
.hm-exec-grid { grid-template-columns: 1fr !important; }
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
.vtabs-layout { grid-template-columns: 1fr !important; }
.vtabs-nav {
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
position: relative !important;
}
.vtabs-nav::before { display: none; }
.vtab-btn { flex: 0 0 auto; white-space: nowrap; }
.retail-workspace, .retail-workspace.with-filters {
grid-template-columns: 1fr !important;
}
@@ -97,31 +89,59 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
.regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; }
.regs-columns { flex-direction: column !important; display: flex !important; }
.pwa-install-bar { display: flex; }
/* Scherper op tablet/mobiel: minder soft blur */
.hm-panel, .hm-chart-panel, .live-digest, .briefing-card,
.ha-panel, .panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
@media (max-width: 768px) {
.mobile-menu-btn { display: inline-flex; }
.mobile-menu-btn { display: inline-flex !important; }
.mobile-nav-overlay { display: block; }
body:not(.nav-open) .mobile-nav-overlay { display: none; }
body:not(.nav-open) .mobile-nav-overlay { display: none !important; }
.app-shell {
grid-template-columns: 1fr !important;
display: block !important;
}
.app-main {
margin-left: 0 !important;
width: 100% !important;
max-width: 100vw !important;
}
/* Hard overrides — must beat app-sidebar-neo.css */
.app-sidebar {
position: fixed !important;
top: 0; left: 0; bottom: 0;
width: min(280px, 88vw);
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
width: min(280px, 88vw) !important;
height: 100dvh !important;
z-index: 999;
transform: translateX(-105%);
transition: transform 0.3s ease;
z-index: 1000 !important;
transform: translateX(-105%) !important;
transition: transform 0.3s ease !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
padding-top: max(1rem, var(--safe-top));
padding-top: max(1rem, var(--safe-top)) !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
}
body.nav-open .app-sidebar { transform: translateX(0); }
.app-sidebar-resizer { display: none !important; }
.persona-nav {
flex: none;
min-height: auto;
overflow: visible;
padding-bottom: 0;
}
body.nav-open .app-sidebar { transform: translateX(0) !important; }
body.nav-open { overflow: hidden; }
.app-topbar {
position: sticky; top: 0; z-index: 100;
padding-top: max(0.5rem, var(--safe-top));
@@ -131,16 +151,296 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
.retail-actions .btn, .herman-briefing-actions .btn {
min-width: 100%; flex: 1 1 100%;
}
.herman-briefing-actions {
flex-direction: column;
}
.herman-briefing-actions .btn {
width: 100%;
}
}
@media (max-width: 480px) {
.kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis {
grid-template-columns: 1fr !important;
}
.page-header h1 { font-size: 1.2rem !important; }
}
.pwa-install-bar {
display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 10100;
padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, var(--safe-bottom));
background: rgba(8, 12, 18, 0.95);
background: rgba(8, 12, 18, 0.97);
border-top: 1px solid rgba(148, 163, 184, 0.15);
align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;
}
.pwa-install-bar p { margin: 0; font-size: 0.85rem; color: #cbd5e1; flex: 1; }
@media (display-mode: standalone) { .pwa-install-bar { display: none !important; } }
.pwa-install-hint {
display: none;
width: 100%;
margin: 0;
font-size: 0.78rem;
color: #94a3b8;
line-height: 1.35;
}
.pwa-install-bar.is-ios .pwa-install-hint.is-ios-hint { display: block; }
.pwa-install-bar.is-android .pwa-install-hint.is-android-hint { display: block; }
.pwa-install-bar:not(.is-ios):not(.is-android) .pwa-install-hint.is-android-hint { display: block; }
@media (display-mode: standalone), (display-mode: fullscreen) {
.pwa-install-bar { display: none !important; }
}
/* ===== War Room FULL CHAT on phone (Android + iOS) ===== */
@media (max-width: 900px) {
/* Tabs: compact chip row */
.vtabs-layout {
display: flex !important;
flex-direction: column !important;
gap: 0.3rem !important;
min-height: 0 !important;
}
.vtabs-nav {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
width: 100% !important;
max-width: 100% !important;
position: sticky !important;
top: 0 !important;
z-index: 50 !important;
padding: 0.3rem !important;
margin: 0 !important;
}
.vtabs-nav .vtab-btn {
flex: 0 0 auto !important;
width: auto !important;
white-space: nowrap !important;
min-height: 40px !important;
padding: 0.35rem 0.6rem !important;
}
.vtabs-nav-header { display: none !important; }
.vtabs-content { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; min-height: 0 !important; }
/* Hide bulky page chrome in War Room */
.hm-neo.tab-warroom-active > .wr-page-header,
.hm-neo.tab-warroom-active > .page-header {
display: none !important;
}
.hm-neo.tab-warroom-active {
display: flex !important;
flex-direction: column !important;
height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
max-height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.hm-neo.tab-warroom-active .vtabs-layout {
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.hm-neo.tab-warroom-active .vtabs-content {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
/* Let Alpine x-show hide inactive tabs; only stretch the visible warroom section */
.hm-neo.tab-warroom-active .vtabs-content > section:not([style*="display: none"]) {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root > .panel {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
max-height: none !important;
padding: 0.35rem !important;
margin: 0 !important;
border-radius: 10px !important;
}
.wr-layout {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
height: 100% !important;
max-height: none !important;
min-height: 0 !important;
gap: 0.3rem !important;
}
.wr-mobile-bar {
display: flex !important;
flex: 0 0 auto;
gap: 0.3rem;
align-items: center;
justify-content: space-between;
}
.wr-layout.roster-collapsed .wr-roster { display: none !important; }
.wr-roster {
flex: 0 0 auto !important;
max-height: 18vh !important;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
border: 0 !important;
border-bottom: 1px solid rgba(148,163,184,0.15) !important;
padding: 0 0 0.25rem !important;
}
.wr-roster h3 { display: none !important; }
.wr-room-btn, .wr-agent-btn {
flex: 0 0 auto !important;
width: auto !important;
min-width: 6rem;
}
.wr-main {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: auto !important;
max-height: none !important;
}
/* Compact toolbar */
.wr-head {
flex: 0 0 auto !important;
margin: 0 !important;
gap: 0.25rem !important;
}
.wr-head-title h2 { font-size: 0.9rem !important; }
.wr-head-title .muted,
.wr-head-title .wr-msg-count { display: none !important; }
.wr-head-actions {
width: 100%;
gap: 0.25rem !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
.wr-head-actions .btn {
flex: 0 0 auto;
min-height: 36px !important;
padding: 0.3rem 0.45rem !important;
font-size: 0.75rem !important;
}
.wr-head-actions .wr-a2a-btn,
.wr-head-actions button[title="Open op tweede scherm"],
.wr-head-actions button[title="Spraakmeeting met agents"],
.wr-head-actions button[title="Snel praten"] {
display: none !important;
}
/* THE chat window — take all remaining space */
.wr-log {
flex: 1 1 auto !important;
min-height: 50dvh !important;
height: auto !important;
max-height: none !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
overscroll-behavior: contain;
padding: 0.5rem !important;
}
/* Readable bubbles */
.wr-bubble { gap: 0.4rem !important; margin-bottom: 0.55rem !important; }
.wr-avatar { width: 34px !important; height: 34px !important; flex-basis: 34px !important; }
.wr-avatar img, .wr-avatar .agent-char-wrap,
.wr-avatar .agent-char-wrap svg,
.wr-avatar-fallback { width: 34px !important; height: 34px !important; }
.wr-bubble-body {
max-width: calc(100% - 42px) !important;
width: auto !important;
padding: 0.45rem 0.55rem !important;
}
.wr-bubble-text {
font-size: 1rem !important;
line-height: 1.45 !important;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.wr-bubble-meta { font-size: 0.72rem !important; }
.wr-msg-reply, .wr-msg-del { opacity: 0.8 !important; }
.wr-composer {
flex: 0 0 auto !important;
position: relative !important;
bottom: auto !important;
z-index: 20;
margin-top: 0.25rem !important;
padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)) !important;
background: rgba(8, 12, 18, 0.98);
}
.wr-composer textarea,
.wr-composer .form-input {
font-size: 16px !important;
min-height: 48px !important;
line-height: 1.35 !important;
}
.wr-composer .btn-pulse-green {
min-height: 48px !important;
min-width: 4.5rem !important;
}
.wr-ceo-panel {
width: 100% !important;
max-height: 85dvh !important;
}
}
/* Less top padding around agents content on phone */
@media (max-width: 900px) {
.app-main .main-topnav {
padding-top: 0.45rem !important;
padding-bottom: 0.45rem !important;
}
body:has(.tab-warroom-active) .app-main .main-topnav .page-tip { display: none !important; }
}
@supports not (height: 100dvh) {
@media (max-width: 900px) {
.hm-neo.tab-warroom-active {
height: calc(100vh - 3.25rem) !important;
max-height: calc(100vh - 3.25rem) !important;
}
.wr-log { min-height: 50vh !important; }
}
}
/* reply-target mobile */
@media (max-width: 900px) {
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid #38bdf8;
}
.wr-msg-del { opacity: 0.35 !important; }
.wr-msg-reply { opacity: 1 !important; font-weight: 600; }
}
+153 -8
View File
@@ -12,8 +12,9 @@ body {
background: var(--bg-root);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
font-size: 16px;
line-height: 1.55;
letter-spacing: 0.01em;
}
a {
@@ -25,6 +26,32 @@ a:hover {
text-decoration: underline;
}
/* Themed scrollbars (Firefox + WebKit) */
html {
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(8, 12, 18, 0.65);
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: rgba(8, 12, 18, 0.55);
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.45);
border-radius: 999px;
border: 2px solid rgba(8, 12, 18, 0.55);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.6);
}
.layout {
display: flex;
min-height: 100vh;
@@ -129,7 +156,7 @@ a:hover {
.panel h2 {
margin: 0 0 1rem;
font-size: 0.8rem;
font-size: 1.0625rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
@@ -369,7 +396,7 @@ a:hover {
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
font-size: 0.9375rem;
}
.data-table th,
@@ -382,7 +409,7 @@ a:hover {
.data-table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.7rem;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -458,7 +485,7 @@ a:hover {
.drawer.open { transform: translateX(0); }
.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; }
.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.btn-sm { padding: .25rem .55rem; font-size: 12px; }
.btn-sm { padding: 0.35rem 0.75rem; font-size: 0.875rem; min-height: 2rem; }
.clickable-row { cursor: pointer; transition: background .15s; }
.clickable-row:hover { background: var(--bg-hover); }
.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; }
@@ -485,7 +512,7 @@ a:hover {
.chat-msg.user { background: rgba(0,200,255,.08); }
.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); }
.chat-input { display: flex; gap: .5rem; }
.page-tip { color: var(--text-muted); font-size: 13px; }
.page-tip { color: var(--text-muted); font-size: 0.9375rem; line-height: 1.5; }
.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; }
.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; }
body.drawer-open { overflow: hidden; }
@@ -648,7 +675,7 @@ body.drawer-open { overflow: hidden; }
}
@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } }
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; }
.form-label { display: block; font-size: 0.875rem; color: var(--text-muted); margin-bottom: 0.75rem; }
.form-label .form-input { margin-top: 0.25rem; }
.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; }
@@ -865,3 +892,121 @@ body:has(.topbar) .main { margin-left: 0; }
border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
}
.novnc-fallback a { color: var(--accent-cyan); }
/* ── AI / LLM settings ── */
.llm-hero-card {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding: 1.1rem 1.25rem;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(8, 12, 18, 0.95));
}
.llm-hero-body { flex: 1 1 280px; min-width: 0; }
.llm-hero-badge {
display: inline-block;
margin-bottom: 0.45rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(56, 189, 248, 0.25);
}
.llm-hero-card h3 {
margin: 0 0 0.4rem;
font-size: 1.05rem;
font-weight: 600;
color: #f1f5f9;
}
.llm-hero-lead {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: #94a3b8;
max-width: 42rem;
}
.llm-hero-lead code {
font-size: 0.8rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(15, 23, 42, 0.8);
color: #bae6fd;
}
.llm-hero-steps {
margin: 0.65rem 0 0;
padding-left: 1.15rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #94a3b8;
}
.llm-hero-steps a { color: #38bdf8; }
.llm-section-title {
margin: 0 0 0.25rem;
font-size: 0.92rem;
font-weight: 600;
color: #e2e8f0;
}
.llm-section-sub {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: #64748b;
}
.llm-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.65rem;
margin-bottom: 1.5rem;
}
.llm-preset-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
min-height: 7.5rem;
padding: 0.85rem 0.95rem;
text-align: left;
cursor: pointer;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.55);
color: inherit;
font: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.12s;
}
.llm-preset-card:hover {
border-color: rgba(56, 189, 248, 0.35);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.llm-preset-name {
font-size: 0.875rem;
font-weight: 600;
color: #f1f5f9;
line-height: 1.25;
}
.llm-preset-hint {
font-size: 0.75rem;
line-height: 1.4;
color: #64748b;
flex: 1;
}
.llm-preset-models {
font-size: 0.68rem;
font-family: ui-monospace, monospace;
color: #475569;
word-break: break-all;
line-height: 1.35;
}
.llm-provider-list { margin-top: 0.25rem; }
+442
View File
@@ -0,0 +1,442 @@
/* Parse Intelligence Workbench */
.pi-workbench {
--pi-accent: #00e5ff;
--pi-gold: #ffd700;
--pi-purple: #a855f7;
--pi-bg: rgba(6, 10, 18, 0.92);
border-radius: 16px;
border: 1px solid rgba(0, 229, 255, 0.12);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
}
.pi-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
}
.pi-header h2 {
margin: 0;
font-size: 1.15rem;
background: linear-gradient(90deg, #f1f5f9, #00e5ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.pi-kpi-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
@media (max-width: 900px) {
.pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
}
.pi-kpi-cell {
padding: 0.85rem 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
transition: background 0.2s;
}
.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
.pi-kpi-cell:last-child { border-right: none; }
.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
.pi-body {
display: grid;
grid-template-columns: 260px 1fr 380px;
min-height: 520px;
}
@media (max-width: 1200px) {
.pi-body { grid-template-columns: 240px 1fr; }
.pi-detail { display: none; }
.pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
}
.pi-filters {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(0, 0, 0, 0.2);
overflow-y: auto;
max-height: 720px;
}
.pi-filters label {
display: block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin: 0.85rem 0 0.35rem;
font-weight: 700;
}
.pi-filters label:first-child { margin-top: 0; }
.pi-search {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(0, 229, 255, 0.25);
background: rgba(0, 0, 0, 0.35);
color: #f1f5f9;
font-size: 0.85rem;
}
.pi-search:focus {
outline: none;
border-color: rgba(0, 229, 255, 0.55);
box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
}
.pi-select, .pi-range {
width: 100%;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.8);
color: #e2e8f0;
font-size: 0.82rem;
}
.pi-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #cbd5e1;
margin: 0.45rem 0;
cursor: pointer;
}
.pi-toggle-row input { accent-color: #00e5ff; }
.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
.pi-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(0, 229, 255, 0.12);
border: 1px solid rgba(0, 229, 255, 0.35);
color: #7dd3fc;
cursor: pointer;
}
.pi-filter-chip button {
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.9rem;
}
.pi-main {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.pi-trends-bar {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(168, 85, 247, 0.04);
}
.pi-trends-bar .lbl {
font-size: 0.68rem;
color: #94a3b8;
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pi-hype-chip {
font-size: 0.76rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(168, 85, 247, 0.35);
background: rgba(168, 85, 247, 0.1);
color: #e9d5ff;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
user-select: none;
}
.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
.pi-hype-chip.active {
border-color: #00e5ff;
background: rgba(0, 229, 255, 0.15);
color: #a5f3fc;
box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
}
.pi-hype-chip.cross {
border-color: rgba(255, 215, 0, 0.45);
background: rgba(255, 215, 0, 0.08);
color: #fde68a;
}
.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
.pi-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.15);
}
.pi-toolbar .count {
font-size: 0.78rem;
color: #94a3b8;
margin-right: auto;
}
.pi-toolbar .count strong { color: #00e5ff; }
.pi-sort-btn {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.pi-sort-btn.active {
border-color: rgba(0, 229, 255, 0.45);
color: #00e5ff;
background: rgba(0, 229, 255, 0.08);
}
.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
.pi-view-toggle button {
font-size: 0.72rem;
padding: 0.3rem 0.65rem;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
}
.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
.pi-table-wrap {
flex: 1;
overflow: auto;
max-height: 420px;
}
.pi-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.pi-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
padding: 0.55rem 0.75rem;
background: rgba(10, 15, 25, 0.98);
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
color: #64748b;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.pi-table th:hover { color: #00e5ff; }
.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
.pi-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
vertical-align: middle;
color: #e2e8f0;
}
.pi-table tr { cursor: pointer; transition: background 0.12s; }
.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
.pi-table tbody tr.selected {
background: rgba(0, 229, 255, 0.1);
box-shadow: inset 3px 0 0 #00e5ff;
}
.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
.pi-badge {
display: inline-block;
font-size: 0.62rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-weight: 700;
}
.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
.pi-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.65rem;
padding: 0.75rem 1rem;
overflow-y: auto;
max-height: 420px;
}
.pi-card {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
padding: 0.85rem;
background: rgba(7, 11, 18, 0.8);
cursor: pointer;
transition: border-color 0.15s, transform 0.12s;
}
.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
.pi-detail {
display: flex;
flex-direction: column;
border-left: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(0, 0, 0, 0.25);
min-width: 0;
}
.pi-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: #475569;
font-size: 0.85rem;
}
.pi-detail-head {
padding: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
.pi-detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.pi-detail-tabs button {
flex: 1;
padding: 0.5rem;
border: none;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.pi-detail-tabs button.active {
color: #00e5ff;
border-bottom-color: #00e5ff;
background: rgba(0, 229, 255, 0.05);
}
.pi-detail-body {
flex: 1;
overflow-y: auto;
padding: 0.85rem 1rem;
max-height: 480px;
}
.pi-read-text {
white-space: pre-wrap;
font-size: 0.78rem;
line-height: 1.6;
color: #cbd5e1;
}
.pi-read-text mark {
background: rgba(255, 215, 0, 0.35);
color: #fef9c3;
padding: 0 0.15rem;
border-radius: 2px;
}
.pi-link-list { list-style: none; padding: 0; margin: 0; }
.pi-link-list li {
padding: 0.35rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
font-size: 0.78rem;
}
.pi-link-list a { color: #86efac; word-break: break-all; }
.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
.pi-heading-item {
font-size: 0.78rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
background: rgba(0, 229, 255, 0.06);
border-left: 3px solid rgba(0, 229, 255, 0.4);
color: #e2e8f0;
}
.pi-loading {
padding: 2rem;
text-align: center;
color: #64748b;
}
.pi-empty {
padding: 2.5rem 1rem;
text-align: center;
color: #64748b;
}
.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+262
View File
@@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
<header class="rc-header">
<div>
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
<p class="rc-subtitle" x-show="meta.file_mtime">
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
· <span x-text="fmtDate(meta.file_mtime)"></span>
</p>
</div>
<div class="rc-header-actions">
<div class="rc-view-toggle">
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
</div>
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
</div>
</header>
<!-- Cockpit: Excel-layout met charts -->
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
<div class="rc-summary-strip">
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / mnd</span>
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / jr</span>
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Deals</span>
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
</div>
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
<div class="rc-style-dots">
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
<template x-for="s in styleFilters" :key="'d-' + s.id">
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
</template>
</div>
</div>
<div class="rc-visual-wrap">
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-deal" />
<col class="rc-col-chart-num" />
<col class="rc-col-chart-num" />
<col class="rc-col-donut" />
<col class="rc-col-steps" />
</colgroup>
<!-- Excel rij 1: goals -->
<tr class="rc-visual-goals">
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
</tr>
<tr class="rc-visual-head">
<td class="rc-vcell">Deal</td>
<td class="rc-vcell rc-num">Marge mnd</td>
<td class="rc-vcell rc-num">Marge jr</td>
<td class="rc-vcell rc-num">Aandeel</td>
<td class="rc-vcell">Next steps</td>
</tr>
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
<tr class="rc-visual-row"
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<td class="rc-vcell rc-vdeal">
<span class="rc-deal-name" x-text="row.name"></span>
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_month">
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_year">
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
</div>
</td>
<td class="rc-vcell rc-vdonut">
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
</div>
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
</td>
<td class="rc-vcell rc-vsteps">
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
</td>
</tr>
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
<td class="rc-expand-cell" colspan="5">
<div class="rc-expand-inner">
<div class="rc-expand-chart">
<div class="rc-bar-chart">
<div class="rc-bar-col">
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
<span>Mnd</span>
</div>
<div class="rc-bar-col">
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
<span>Jr</span>
</div>
</div>
<div class="rc-expand-stats">
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
</div>
</div>
<div class="rc-expand-body">
<h4>Next steps</h4>
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
<div class="rc-expand-task" x-show="row.db_id">
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</table>
</div>
</div>
<!-- Bewerken: originele Excel -->
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
<div class="rc-toolbar">
<span class="rc-toolbar-title">Bewerken · DB</span>
<div class="rc-toolbar-actions">
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
</div>
</div>
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
<table class="rc-sheet" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-num" />
<col class="rc-col-num" />
<col class="rc-col-steps" />
</colgroup>
<tr class="rc-row rc-row-goals">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.vision_text || ''"
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.horizon_text || ''"
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.mid_text || ''"
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
x-init="$el.textContent = goals.tagline || ''"
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
</tr>
<tr class="rc-row rc-row-head">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-th">Deal</td>
<td class="rc-cell rc-th rc-num">Marge Month</td>
<td class="rc-cell rc-th rc-num">Marge Year</td>
<td class="rc-cell rc-th">Next steps</td>
</tr>
<template x-for="row in sheetRows" :key="row.id || row.source_row">
<tr class="rc-row rc-data-row"
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
@click="selectRow(row)">
<template x-if="row.logoCell === 'cucina'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
</td>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-foodlinkk">
<span class="rc-fl-brand">Foodlinkk</span>
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
</div>
</td>
</template>
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.name || ''"
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_month)"
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_year)"
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.next_steps || ''"
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
</tr>
</template>
</table>
</div>
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
<div class="rc-row-panel-head">
<strong x-text="selectedRow?.name"></strong>
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
<option value="green">Groen (live)</option>
<option value="red">Rood (inactief)</option>
<option value="orange">Oranje</option>
<option value="yellow">Geel (sectie)</option>
<option value="blue">Blauw</option>
<option value="white">Wit</option>
</select>
</div>
<div class="rc-row-panel-body">
<label>Agent taak
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
</div>
</label>
</div>
</div>
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=5"></script>
{% endblock %}
+12
View File
@@ -24,6 +24,18 @@
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
/* Typography scale (16px root) */
--text-xs: 0.8125rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-md: 1.0625rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.75rem;
--leading: 1.55;
--leading-tight: 1.35;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--sidebar-width: 240px;
}
+13
View File
@@ -194,3 +194,16 @@
.nav-pill.active { animation: none; }
.nav-pill:hover { transform: none; }
}
/* Sidebar: app-sidebar-neo.css only — no topnav glow/pulse */
.app-sidebar .nav-pill,
.app-sidebar .nav-pill:hover,
.app-sidebar .nav-pill.active {
animation: none !important;
box-shadow: none !important;
text-shadow: none !important;
}
.app-sidebar .nav-pill::before,
.app-sidebar .nav-pill::after {
display: none !important;
}
+147
View File
@@ -0,0 +1,147 @@
/* Topbar identity chip */
.app-user-chip {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-left: 0.65rem;
padding: 0.2rem 0.35rem 0.2rem 0.2rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.app-user-chip input[type="file"],
.user-avatar-input {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
.user-avatar-btn {
appearance: none;
border: 0;
padding: 0;
margin: 0;
background: transparent;
cursor: pointer;
border-radius: 50%;
line-height: 0;
flex-shrink: 0;
}
.user-avatar-btn:focus-visible {
outline: 2px solid rgba(251, 191, 36, 0.65);
outline-offset: 2px;
}
.user-avatar-img,
.user-avatar-fallback {
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(251, 191, 36, 0.55);
background: linear-gradient(145deg, #1e293b, #0f172a);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.user-avatar-img {
display: block;
}
.user-avatar-fallback {
display: none; /* Alpine x-show zet dit aan indien geen foto */
align-items: center;
justify-content: center;
color: #fbbf24;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Als Alpine display:flex/inline-flex zet via x-show */
.user-avatar-fallback[style*="flex"] {
display: inline-flex !important;
}
.user-avatar-meta {
display: flex;
flex-direction: column;
min-width: 0;
padding-right: 0.15rem;
line-height: 1.15;
}
.user-avatar-name {
font-size: 0.8rem;
font-weight: 600;
color: #e2e8f0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-avatar-role {
font-size: 0.65rem;
color: #94a3b8;
text-transform: capitalize;
}
.user-logout-btn {
min-height: 30px !important;
padding: 0.2rem 0.65rem !important;
font-size: 0.7rem !important;
border-radius: 999px !important;
margin-right: 0.15rem;
}
@media (max-width: 640px) {
.user-avatar-meta { display: none; }
.app-user-chip {
padding: 0.15rem;
gap: 0.35rem;
}
}
/* Settings list */
.user-card-avatar,
.user-form-avatar-preview {
width: 48px;
height: 48px;
border-radius: 50% !important;
object-fit: cover;
border: 2px solid rgba(251, 191, 36, 0.4);
background: #1e293b;
flex-shrink: 0;
}
.user-card-avatar.fallback,
.user-form-avatar-preview.fallback {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fbbf24;
font-weight: 700;
}
.user-form-avatar-preview {
width: 72px;
height: 72px;
}
.user-form-avatar-row {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 0.85rem;
}

Some files were not shown because too many files have changed in this diff Show More