Compare commits

..

10 Commits

Author SHA1 Message Date
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
188 changed files with 52091 additions and 1361 deletions
+3
View File
@@ -13,3 +13,6 @@ cockpit/static/uploads/
*.swp
.idea/
.vscode/
.cursor/
tmp/
cockpit/tmp-upload/
+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}
+4
View File
@@ -10,6 +10,10 @@ 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")
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_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")
+21 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
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,7 +32,10 @@ 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.agents_api import router as agents_api_router
@@ -41,8 +45,21 @@ 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")
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
@@ -66,6 +83,9 @@ for r in (
hermes.router,
packaging.router,
ops.router,
revenue_cockpit.router,
export_intel.router,
revenue_cockpit_api,
api.router,
ops_api.router,
admin_router,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ 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"}:
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
active_tab = "souls"
events: list = []
try:
+355 -34
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.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")
+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")
+68 -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,64 @@ 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)
+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
+293
View File
@@ -0,0 +1,293 @@
"""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.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"):
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
+1 -1
View File
@@ -10,7 +10,7 @@ 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", "permissions", "social", "ai")
return templates.TemplateResponse(
"settings.html",
{
+115
View File
@@ -381,3 +381,118 @@ 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]:
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("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}
+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
+140
View File
@@ -0,0 +1,140 @@
"""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)
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
]
+22
View File
@@ -0,0 +1,22 @@
"""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",
"branch_scraper": "sourcing",
"hermes": "herman",
"herman_delegate": "herman",
}
def normalize_agent_key(name: str | None) -> str:
key = (name or "").strip().lower()
if not key:
return ""
return AGENT_NAME_MAP.get(key, key)
+456
View File
@@ -0,0 +1,456 @@
"""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_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",
]
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 == "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}
+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
+338 -34
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
@@ -352,7 +365,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 +427,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] = {
@@ -565,9 +655,232 @@ 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]:
"""Gestructureerde live samenvatting — Cucina B2C + Foodlinkk B2B + cockpit."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
pending_n = int(data.get("pending_approvals") or 0)
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 {}
sections: list[dict[str, Any]] = []
# —— Foodlinkk B2B ——
fl_lines: list[str] = [
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
]
if export:
fl_lines.append(
f"Wereldexport: {export.get('distributors', 0):,} distributeurs · "
f"{export.get('entities', 0):,} entiteiten · {export.get('tenders_open', 0)} open tenders · "
f"fase {export.get('phase', '—')}"
)
if export.get("crm_linked"):
fl_lines.append(f"Export → CRM gekoppeld: {export.get('crm_linked')} records")
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
fl_projects = sorted(
fl_projects,
key=lambda p: (
1 if "verkopen" in (p.get("name") or "").lower() else 0,
-float(p.get("margin_month") or 0),
),
)
if fl_projects:
fl_lines.append("Revenue projecten: " + "; ".join(_revenue_line(p) for p in fl_projects[:3]))
if rev_goals.get("vision_text"):
vision = str(rev_goals["vision_text"]).strip().split("\n")[0][:90]
fl_lines.append(f"Visie: {vision}")
sections.append({"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines})
# —— Cucina B2C ——
cu_lines: list[str] = [
f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships",
f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items",
]
if rev_stats:
cu_lines.insert(
0,
f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · "
f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen",
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
if cu_projects:
cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4]))
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
if halal_items:
cu_lines.append("🎯 Halal kansen (live engine):")
for rec in halal_items[:4]:
reason = (rec.get("reasons") or [""])[0]
cu_lines.append(
f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')} — "
f"score {rec.get('score')} · {reason}"
)
elif opp:
top = opp[0]
cu_lines.append(
f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — "
f"score {round(float(top.get('halal_opportunity_score') or 0))}/100"
)
ms = data.get("milestones_pending") or []
if ms:
cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})")
sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines})
# —— Vandaag in cockpit ——
cockpit_lines: list[str] = []
if pending_n:
cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou")
for row in (data.get("pending_approval_requests") or [])[:3]:
cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}")
for line in activity[:5]:
cockpit_lines.append(_short_activity(line))
if not cockpit_lines:
cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.")
sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "⚡", "lines": cockpit_lines[:6]})
# —— Markt & kennis ——
news_lines: list[str] = []
if market.get("best_performer"):
bp = market["best_performer"]
news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
seen_news: set[str] = set()
for row in (data.get("trending_food") or []):
title = str(row.get("title") or "").strip()[:95]
if not title or title in seen_news:
continue
seen_news.add(title)
news_lines.append(title)
if len(seen_news) >= 2:
break
if data.get("nas_docs"):
news_lines.append(
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
)
if news_lines:
sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]})
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = (row.get("title") or row.get("action_type") or "goedkeuring")[:60]
actions.append(f"@{row.get('agent_key')}: {label}")
for row in (data.get("milestones_pending") or [])[:2]:
actions.append(f"Milestone: {row.get('title')}")
for row in (revenue.get("by_brand") or {}).get("cucina") or []:
if row.get("open_objectives"):
actions.append(f"Cucina follow-up: {row.get('name')}")
break
for rec in halal_items[:2]:
actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
if export.get("tenders_open"):
actions.append(f"Wereldexport: {export.get('tenders_open')} open tenders bekijken")
if not actions:
actions.extend([
"Retail 360: top-3 halal-gap filialen benaderen",
"Marketing Hub: kant-en-klaar trends checken",
])
long_term = [
"Cucina: schaal supermarkt-listings (AH, Jumbo, PLUS) naar actieve partnerships",
"Foodlinkk: distributeurs in Wereldexport koppelen aan CRM-deals",
"Revenue Cockpit: open stappen en marges wekelijks reviewen",
]
if rev_goals.get("horizon_text"):
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
# Platte summary voor backwards compat (notifications)
summary = f"{data['date']} · Foodlinkk B2B + Cucina B2C · {len(activity)} activiteiten vandaag"
return {
"summary": summary,
"sections": sections,
"actions": actions[:6],
"long_term": long_term[:5],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"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']}",
@@ -644,6 +957,15 @@ def build_template_report(data: dict[str, Any]) -> str:
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"]:
@@ -683,45 +1005,25 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
"Schrijf warm, professioneel, actionable."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
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}")
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)
@@ -775,4 +1077,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),
}
+179 -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,135 @@ 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(
message: str,
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: 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 +265,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 +279,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 +326,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 {
@@ -184,24 +348,29 @@ async def chat(message: str) -> dict[str, Any]:
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:
return {
+277
View File
@@ -0,0 +1,277 @@
"""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": [
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-r1:free",
"meta-llama/llama-3.3-70b-instruct:free",
],
"needs_key": True,
"signup_url": "https://openrouter.ai/",
"hint": "Veel gratis modellen via één API.",
},
"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.",
},
"custom_openai": {
"label": "Custom OpenAI-compatible",
"api_base_url": "",
"models": [],
"needs_key": True,
"hint": "Elke API die /v1/chat/completions ondersteunt.",
},
}
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]:
row = get_provider(provider_id)
if not row:
return {
"id": 0,
"label": "Ollama lokaal",
"provider_type": "ollama",
"api_base_url": settings.OLLAMA_URL,
"api_key": "",
"model": settings.OLLAMA_MODEL,
"extra_config": {},
}
return row
async def chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
) -> 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(extra.get("temperature", 0.4)),
"max_tokens": int(extra.get("max_tokens", 2048)),
}
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,
}
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,),
)
+529 -62
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:
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,
}
fetched = _fetch_page(url)
if not fetched:
msg = f"Kan {url} niet bereiken"
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "ERROR", msg),
)
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")
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),
)
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
)
result["status"] = "CHANGE"
else:
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
)
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),
)
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:
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
return {"ok": True, "method": "worker"}
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
from app.db import fetch_all
term_scores: dict[str, dict[str, Any]] = {}
heading_counts: dict[str, dict[str, Any]] = {}
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"
)
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)
changed = 0
with get_connection() as conn:
with conn.cursor() as cur:
for site in sites:
fetched = _fetch_page(site["url"])
if not fetched:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
)
continue
final_url, title, text, html = fetched
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(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site["id"], old_hash, new_hash),
)
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
)
changed += 1
else:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "OK", f"Crawl OK — {title}"),
)
cur.execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(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)}
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"),
},
}
+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
+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"
}
}
+1
View File
@@ -7,4 +7,5 @@ httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
openpyxl>=3.1.0
tweepy==4.15.0
+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; }
}
+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 + '%, rgba(148,163,184,0.15) ' + 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 %}
+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;
+180
View File
@@ -0,0 +1,180 @@
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.app-sidebar {
position: sticky;
top: 0;
align-self: start;
height: auto;
max-height: none;
overflow: visible;
overflow-x: hidden;
padding: 0.75rem 0.6rem;
background: #080c12;
border-right: 1px solid rgba(148, 163, 184, 0.1);
}
.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 { display: contents; }
.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;
}
.sidebar-nav-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.95rem;
background: rgba(8, 12, 18, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
.sidebar-nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 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); }
+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; }
+56
View File
@@ -0,0 +1,56 @@
/* Wereldexport — KPIs + layout lock */
.wereldexport-workspace.vtabs-layout {
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
gap: 1rem !important;
}
.wereldexport-workspace > .vtabs-nav {
flex: 0 0 220px !important;
width: 220px !important;
max-width: 220px !important;
}
.wereldexport-workspace > .vtabs-content {
flex: 1 1 0 !important;
min-width: 0 !important;
}
.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; }
+466
View File
@@ -0,0 +1,466 @@
/* Export Intel — page layout */
.ei-page { --ei-accent: #0ea5e9; }
.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.ei-header h1 { font-size: 1.5rem; margin: 0; }
.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
.ei-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 1rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
border: 1px solid rgba(148,163,184,0.15);
border-radius: 12px;
}
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
.ei-filter-label select,
.ei-filter-label input {
font-size: 0.82rem;
padding: 0.45rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148,163,184,0.22);
background: #0a0e12;
color: #e2e8f0;
min-width: 8rem;
}
.ei-filter-search-wrap input { width: 100%; min-width: 0; }
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
.ei-split { align-items: stretch; }
.ei-split > .ei-map-wrap { width: 100%; }
.ei-split > .ei-panel { width: 100%; max-height: none; }
.ei-progress-overlay {
position: fixed; inset: 0; z-index: 1200;
display: flex; align-items: center; justify-content: center;
background: rgba(4, 8, 14, 0.55); backdrop-filter: blur(2px);
}
.ei-progress-card {
min-width: 260px; max-width: 90vw; padding: 1.1rem 1.25rem;
border-radius: 14px; border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(10, 14, 20, 0.95); text-align: center;
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
}
.ei-progress-card p { margin: 0.65rem 0 0.85rem; color: #e2e8f0; font-size: 0.88rem; }
.ei-progress-spinner {
width: 28px; height: 28px; margin: 0 auto; border-radius: 50%;
border: 3px solid rgba(56, 189, 248, 0.2); border-top-color: #38bdf8;
animation: ei-spin 0.75s linear infinite;
}
.ei-progress-bar {
height: 4px; border-radius: 999px; background: rgba(148, 163, 184, 0.2); overflow: hidden;
}
.ei-progress-bar-fill {
height: 100%; width: 35%; border-radius: 999px;
background: linear-gradient(90deg, #0ea5e9, #38bdf8, #0ea5e9);
background-size: 200% 100%; animation: ei-progress-slide 1.2s ease-in-out infinite;
}
@keyframes ei-progress-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
.ei-th-actions, .ei-td-actions { white-space: nowrap; width: 1%; }
.ei-td-actions { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.ei-td-actions .btn { padding: 0.2rem 0.45rem; font-size: 0.68rem; }
.ei-drawer-edit { margin: 1rem 0; padding: 0.85rem; border-radius: 12px; border: 1px solid rgba(56,189,248,0.25); background: rgba(8,12,18,0.6); }
.ei-drawer-edit h4 { margin: 0 0 0.65rem; font-size: 0.85rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
.ei-edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; }
.ei-edit-grid label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.68rem; color: #64748b; text-transform: uppercase; }
.ei-edit-grid input, .ei-edit-grid select, .ei-edit-grid textarea {
padding: 0.45rem 0.55rem; 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;
}
.ei-edit-full { grid-column: 1 / -1; }
.ei-edit-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.ei-split {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
}
@media (min-width: 1200px) {
.ei-split.ei-split-side {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr);
grid-template-rows: minmax(480px, 62vh);
align-items: stretch;
}
.ei-split.ei-split-side > .ei-map-wrap { grid-column: 1; grid-row: 1; height: 100%; min-height: 480px; }
.ei-split.ei-split-side > .ei-panel { grid-column: 2; grid-row: 1; max-height: 62vh; overflow: auto; }
}
.ei-map-wrap { position: relative; }
.ei-map-loading {
position: absolute; inset: 0; z-index: 500;
display: flex; align-items: center; justify-content: center; gap: 0.65rem;
background: rgba(8, 12, 18, 0.72); color: #94a3b8; font-size: 0.85rem;
pointer-events: none;
}
.ei-map-spinner {
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid rgba(56, 189, 248, 0.25);
border-top-color: #38bdf8;
animation: ei-spin 0.8s linear infinite;
}
@keyframes ei-spin { to { transform: rotate(360deg); } }
.ei-map-truncated { margin: 0 0 0.65rem; font-size: 0.75rem; color: #64748b; }
.ei-map-error { margin: 0 0 0.65rem; padding: 0.65rem 0.85rem; border-radius: 10px; border: 1px solid rgba(251,113,133,0.35); background: rgba(127,29,29,0.25); color: #fecdd3; font-size: 0.82rem; }
.ei-map-wrap {
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(14,165,233,0.2);
height: min(62vh, 720px);
min-height: 480px;
background: #0a0e14;
}
.ei-map-wrap #ei-map {
height: 100%;
min-height: 480px;
width: 100%;
}
/* Leaflet popups — rich card */
.ei-map-wrap .leaflet-popup-content-wrapper,
.ei-map-wrap .leaflet-popup-tip {
background: #0f1724;
color: #e2e8f0;
border: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
}
.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
.ei-map-wrap .leaflet-popup-close-button {
color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
}
.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
.ei-popup-card { padding: 0.85rem 1rem 1rem; }
.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
.ei-popup-badge {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--badge-color, #38bdf8);
background: rgba(56, 189, 248, 0.12);
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
.ei-map-wrap .leaflet-popup-content .ei-popup-name {
display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
color: #f8fafc !important; line-height: 1.3;
}
.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
.ei-popup-v { color: #cbd5e1; word-break: break-word; }
.ei-popup-v a { color: #38bdf8; text-decoration: none; }
.ei-popup-v a:hover { text-decoration: underline; }
.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
.ei-popup-btn {
width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
}
.ei-popup-btn:hover { filter: brightness(1.08); }
.ei-seg-toggle {
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.ei-seg-btn {
font-size: 0.78rem;
padding: 0.35rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148,163,184,0.25);
background: rgba(15,20,25,0.85);
color: #94a3b8;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ei-seg-btn:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.45); }
.ei-seg-btn.active {
color: #f8fafc;
border-color: rgba(14,165,233,0.65);
background: rgba(14,165,233,0.18);
box-shadow: 0 0 0 1px rgba(14,165,233,0.2);
}
.ei-filter-group-maptypes { flex: 1 1 100%; max-width: 100%; }
.ei-type-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.ei-type-chip {
font-size: 0.72rem;
padding: 0.3rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(148,163,184,0.22);
background: rgba(15,20,25,0.9);
color: #94a3b8;
cursor: pointer;
white-space: nowrap;
}
.ei-type-chip:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.4); }
.ei-type-chip.active {
color: #f0fdf4;
border-color: rgba(34,197,94,0.55);
background: rgba(34,197,94,0.12);
}
.ei-filter-group-halal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.ei-filter-check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: #cbd5e1;
cursor: pointer;
}
.ei-halal-min {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
}
.ei-halal-min input[type=range] { width: 90px; }
.ei-legend-row { display: flex; flex-wrap: wrap; gap: 0.65rem 1rem; }
.ei-pin-hot .ei-pin-ring { box-shadow: 0 0 0 3px rgba(34,197,94,0.45); }
.ei-halal-markets { margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-halal-markets h4 { margin: 0 0 0.45rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.ei-halal-market-row {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 0.35rem;
align-items: center;
font-size: 0.78rem;
padding: 0.25rem 0;
}
.ei-halal-market-row:hover { color: #38bdf8; }
.ei-halal-score { color: #22c55e; font-size: 0.82rem; }
.ei-halal-pill {
display: inline-block;
min-width: 2rem;
text-align: center;
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 700;
}
.ei-halal-pill.ei-halal-hot { background: rgba(34,197,94,0.2); color: #4ade80; }
.ei-halal-pill.ei-halal-warm { background: rgba(234,179,8,0.2); color: #facc15; }
.ei-halal-pill.ei-halal-mild { background: rgba(249,115,22,0.2); color: #fb923c; }
.ei-list-focus {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
border: 1px solid rgba(34,197,94,0.35);
background: rgba(34,197,94,0.06);
}
.ei-list-focus-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.ei-list-focus-title { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f8fafc; line-height: 1.3; }
.ei-list-focus-meta { margin: 0 0 0.65rem; font-size: 0.75rem; color: #94a3b8; }
.ei-list-focus-kv { display: grid; grid-template-columns: 72px 1fr; gap: 0.3rem; font-size: 0.78rem; margin-bottom: 0.3rem; }
.ei-list-focus-kv span:first-child { color: #64748b; }
.ei-list-focus-kv a { color: #38bdf8; word-break: break-all; }
.ei-list-focus-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.65rem; }
.ei-halal-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0; }
.ei-halal-tag {
font-size: 0.68rem;
padding: 0.15rem 0.45rem;
border-radius: 6px;
background: rgba(14,165,233,0.12);
color: #7dd3fc;
border: 1px solid rgba(14,165,233,0.2);
}
.ei-row-active { background: rgba(14,165,233,0.12) !important; }
.ei-row-name { font-weight: 600; color: #e2e8f0; }
.ei-contact-cell { font-size: 0.72rem; color: #94a3b8; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ei-type-pill-sm { font-size: 0.65rem; padding: 0.1rem 0.35rem; }
.ei-contact-row-compact { margin-bottom: 0.45rem; font-size: 0.78rem; }
.ei-contact-source { display: block; font-size: 0.68rem; color: #64748b; }
.ei-drawer-coords { font-size: 0.78rem; color: #94a3b8; }
.ei-drawer-coords a { margin-left: 0.5rem; color: #38bdf8; }
.ei-selection-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin: 0.5rem 0 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(14,165,233,0.25);
background: rgba(14,165,233,0.06);
}
.ei-selection-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.ei-selection-count { font-size: 0.78rem; font-weight: 700; color: #38bdf8; }
.ei-selection-hint { font-size: 0.72rem; color: #64748b; }
.ei-th-check, .ei-td-check { width: 28px; text-align: center; }
.ei-th-fav, .ei-td-fav { width: 32px; text-align: center; }
.ei-fav-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
color: #64748b;
padding: 0;
line-height: 1;
}
.ei-fav-btn.active, .ei-fav-btn:hover { color: #facc15; }
.ei-row-selected { background: rgba(14,165,233,0.08) !important; }
.ei-row-crm .ei-row-name { padding-right: 0.25rem; }
.ei-crm-badge {
display: inline-block;
margin-left: 0.35rem;
font-size: 0.58rem;
font-weight: 800;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(34,197,94,0.2);
color: #4ade80;
vertical-align: middle;
}
.ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; }
.ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-modal-root { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.ei-modal-card {
position: relative;
z-index: 1302;
width: min(420px, 96vw);
padding: 1.25rem;
border-radius: 14px;
background: #0f1419;
border: 1px solid rgba(14,165,233,0.35);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
.ei-modal-card p { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.75rem; }
.ei-modal-note { font-size: 0.75rem !important; color: #64748b !important; }
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
/* Map pins */
.ei-pin-wrap { background: transparent !important; border: none !important; }
.ei-pin {
width: 30px; height: 30px; position: relative;
display: flex; align-items: center; justify-content: center;
}
.ei-pin-ring {
position: absolute; inset: 0; border-radius: 50%;
background: var(--pin-color);
border: 2px solid rgba(255,255,255,0.9);
box-shadow: 0 2px 10px rgba(0,0,0,0.45);
}
.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
/* Clusters */
.ei-cluster-wrap { background: transparent !important; border: none !important; }
.ei-cluster {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #0ea5e9, #0369a1);
border: 2px solid rgba(255,255,255,0.55);
box-shadow: 0 4px 16px rgba(14,165,233,0.45);
color: #fff; font-weight: 800; font-size: 0.85rem;
}
.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
.ei-map-wrap .marker-cluster-small,
.ei-map-wrap .marker-cluster-medium,
.ei-map-wrap .marker-cluster-large { background: transparent !important; }
.ei-map-wrap .marker-cluster-small div,
.ei-map-wrap .marker-cluster-medium div,
.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
.ei-map-legend {
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
}
.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
.ei-leg-pin { font-style: normal; }
.ei-map-empty { margin-bottom: 0.65rem; }
.ei-map-empty-inner {
padding: 0.85rem 1rem;
border-radius: 10px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
font-size: 0.82rem;
color: #cbd5e1;
}
.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
.ei-map-empty-inner p { margin: 0.25rem 0; }
.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-table-wrap { overflow-x: auto; max-height: min(42vh, 520px); }
.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
.ei-empty-cta { margin-top: 0.75rem; }
.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
.ei-type-distributor { color: #fbbf24; }
.ei-type-wholesaler { color: #eab308; }
.ei-type-contract_caterer { color: #a78bfa; }
.ei-confidence { font-size: 0.65rem; color: #64748b; }
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; }
.ei-drawer-root { position: relative; z-index: 1201; }
.ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); }
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-drawer-meta strong { color: #e2e8f0; }
.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
.ei-drawer-kv span:first-child { color: #64748b; }
.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
.ei-contact-row a { color: #38bdf8; }
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
.ei-tab-panel-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.ei-link-btn { background: none; border: none; padding: 0; color: #38bdf8; cursor: pointer; font: inherit; text-align: left; }
.ei-link-btn:hover { text-decoration: underline; }
.ei-td-actions { white-space: nowrap; }
.ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; }
+148
View File
@@ -0,0 +1,148 @@
/* Herman browser assistant — floating widget */
.ha-fab {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 1500;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid rgba(167, 139, 250, 0.55);
background: linear-gradient(145deg, #1e293b, #0f1419);
color: #f8fafc;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
transition: transform 0.15s, box-shadow 0.15s;
}
.ha-fab:hover { transform: scale(1.06); box-shadow: 0 10px 32px rgba(167, 139, 250, 0.25); }
.ha-fab.is-open { background: linear-gradient(145deg, #4c1d95, #1e1b4b); }
.ha-panel {
position: fixed;
right: 1rem;
bottom: 5rem;
z-index: 1499;
width: min(400px, calc(100vw - 2rem));
max-height: min(560px, calc(100vh - 7rem));
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(167, 139, 250, 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);
}
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
.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-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: 200px;
max-height: 320px;
}
.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;
}
.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.65rem; color: #64748b; padding: 0 0.65rem 0.35rem; }
.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-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; }
.ha-fab { right: 1rem; bottom: 1rem; }
}
+353 -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,94 @@
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; }
+3 -10
View File
@@ -68,16 +68,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;
}
@@ -119,6 +109,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
padding-top: max(1rem, var(--safe-top));
flex-direction: column !important;
flex-wrap: nowrap !important;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
body.nav-open .app-sidebar { transform: translateX(0); }
body.nav-open { overflow: hidden; }
+35 -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; }
+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; }
+493
View File
@@ -0,0 +1,493 @@
/* 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; }
/* Alleen bewerk-modus (echte Excel) — niet lekken naar cockpit */
.rc-sheet .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;
}
.rc-sheet .rc-row-goals td {
background: var(--rc-yellow);
font-weight: 600;
min-height: 48px;
}
.rc-sheet .rc-cell-tagline {
font-size: 14pt;
font-style: italic;
text-align: center;
}
.rc-sheet .rc-row-head td {
background: #fff;
font-weight: 700;
border-bottom: 2px solid #666;
}
.rc-sheet .rc-th { text-align: left; }
.rc-sheet .rc-num { text-align: right; }
.rc-sheet .rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
.rc-sheet .rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
.rc-sheet .rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
.rc-sheet .rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
.rc-sheet .rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
.rc-sheet .rc-style-white td:not(.rc-logo-cell) { background: #fff; }
.rc-sheet .rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
.rc-sheet .rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
.rc-sheet .rc-cell-edit:focus {
outline: 2px solid #217346;
outline-offset: -2px;
background: #fff !important;
color: #000 !important;
}
.rc-sheet .rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
.rc-sheet .rc-cell-deal { font-weight: 600; }
.rc-sheet .rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
.rc-sheet .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.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
.rc-sum-item strong { font-size: 1.05rem; color: #4ade80; 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: 680px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: #111820;
font-family: inherit;
font-size: 0.9rem;
color: #f1f5f9;
}
.rc-col-deal { width: 14%; }
.rc-col-chart-num { width: 12%; }
.rc-col-donut { width: 72px; }
.rc-col-steps { width: auto; }
.rc-visual-goals td {
background: rgba(234, 179, 8, 0.2);
color: #fef3c7;
font-weight: 600;
font-size: 0.85rem;
padding: 8px 12px;
border-bottom: 1px solid rgba(234, 179, 8, 0.25);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-vtagline { font-style: italic; text-align: center; color: #fde047; font-size: 0.95rem; }
.rc-visual-head td {
background: #1a2332;
color: #cbd5e1;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 12px;
border-bottom: 2px solid rgba(148,163,184,0.2);
position: sticky;
top: 0;
z-index: 2;
}
.rc-visual-row { cursor: pointer; }
.rc-visual-row:hover td { background: #1e2a3a !important; }
.rc-visual-row.rc-row-open td { background: #1a3050 !important; }
.rc-visual-row td,
.rc-visual-goals td,
.rc-visual-head td,
.rc-vcell {
border-bottom: 1px solid rgba(148,163,184,0.1);
padding: 10px 12px;
vertical-align: middle;
line-height: 1.45;
background: #151c28;
color: #f1f5f9;
}
/* Status = gekleurde linkerrand, neutrale donkere rij */
.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
.rc-vdeal { font-weight: 700; }
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rc-brand-tag {
display: inline-block;
margin-top: 2px;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
padding: 1px 5px;
border-radius: 3px;
background: rgba(148,163,184,0.15);
}
.rc-brand-tag-fl { color: #fbbf24; background: rgba(251,191,36,0.12); }
.rc-vnum { text-align: right; }
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.92rem; font-weight: 700; color: #4ade80; }
.rc-mini-bar {
height: 5px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
margin-top: 5px;
overflow: hidden;
}
.rc-mini-bar-fill { height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.95; }
.rc-mini-bar-year { background: #38bdf8 !important; }
.rc-vdonut { text-align: center; }
.rc-donut {
width: 40px; height: 40px;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.rc-donut-label { font-size: 0.62rem; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
.rc-donut-empty { color: #64748b; font-size: 0.85rem; }
.rc-vsteps {
font-size: 0.84rem;
color: #cbd5e1;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 6px; }
.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.9rem; line-height: 1.55; white-space: pre-wrap; color: #e2e8f0; }
.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; }
}
+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;
}
+232 -71
View File
@@ -1,117 +1,278 @@
/* Vertical tab navigation with animated active indicator */
/* Vertical tabs — clean module nav + page tabs */
.vtabs-layout {
display: grid;
grid-template-columns: 220px 1fr;
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
gap: 1rem;
align-items: start;
min-height: 60vh;
width: 100%;
min-height: 40vh;
--vt-color: #0ea5e9;
--vt-panel-bg: rgba(10, 14, 20, 0.85);
}
.vtabs-layout > .vtabs-nav {
flex: 0 0 220px;
width: 220px;
max-width: 220px;
}
.vtabs-layout > .vtabs-content {
flex: 1 1 0;
min-width: 0;
}
@media (max-width: 639px) {
.vtabs-layout { flex-direction: column !important; }
.vtabs-layout > .vtabs-nav { flex: 1 1 auto; width: 100%; max-width: none; }
}
.vtabs-nav {
position: sticky;
top: 1rem;
top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
border-radius: 14px;
background: rgba(10, 14, 20, 0.85);
gap: 0.1rem;
padding: 0.65rem;
border-radius: 12px;
background: rgba(10, 14, 20, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
overflow: hidden;
}
.vtabs-nav::before {
content: '';
position: absolute;
left: 0;
top: var(--vt-indicator-top, 0);
width: 3px;
height: var(--vt-indicator-h, 40px);
background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
border-radius: 0 4px 4px 0;
transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
box-shadow: 0 0 12px var(--vt-color, #00e5ff);
height: var(--vt-indicator-h, 36px);
background: var(--vt-color);
border-radius: 0 3px 3px 0;
transition: top 0.25s ease, height 0.2s ease;
}
.vtabs-nav-header {
padding: 0 0.35rem 0.5rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.vtabs-nav-header strong {
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #94a3b8;
}
.vtab-btn {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
gap: 0.55rem;
width: 100%;
padding: 0.65rem 0.85rem;
border: none;
border-radius: 10px;
padding: 0.55rem 0.6rem;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
font-size: 0.9375rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: color 0.2s, background 0.25s, transform 0.2s;
text-decoration: none;
--tab-accent: var(--vt-color, #0ea5e9);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.vtab-btn:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.04);
transform: translateX(4px);
}
.vtab-btn.active {
color: #fff;
background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent);
text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent);
}
.vtab-icon { font-size: 1.1rem; flex-shrink: 0; }
.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; }
@keyframes vtabFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
.vtab-btn::before { display: none; }
.vtab-icon,
.ei-vtab-icon-wrap {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.1);
}
/* App sidebar layout */
.app-shell {
display: grid;
grid-template-columns: 200px 1fr;
min-height: 100vh;
.vtab-btn:hover {
background: rgba(30, 41, 59, 0.5);
color: #e2e8f0;
transform: none;
}
.app-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: 1rem 0.65rem;
background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95));
border-right: 1px solid rgba(148, 163, 184, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
.vtab-btn.active {
background: rgba(14, 165, 233, 0.08);
border-color: rgba(14, 165, 233, 0.2);
color: #f1f5f9;
text-shadow: none;
box-shadow: none;
}
.app-sidebar-brand {
padding: 0.5rem 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
margin-bottom: 0.5rem;
.vtab-btn.active .vtab-icon,
.vtab-btn.active .ei-vtab-icon-wrap {
border-color: var(--tab-accent);
background: rgba(14, 165, 233, 0.12);
box-shadow: none;
transform: none;
}
.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; }
.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; }
.app-sidebar-group { margin-bottom: 0.75rem; }
.app-sidebar-label {
font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em;
color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem;
.vtab-badge,
.ei-vtab-badge {
margin-left: auto;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.15);
color: #cbd5e1;
border: none;
min-width: 1.25rem;
text-align: center;
}
.app-sidebar .nav-pill {
display: flex; width: 100%; justify-content: flex-start;
margin-bottom: 0.2rem; border-radius: 10px;
.vtab-btn.active .ei-vtab-badge,
.vtab-btn.active .vtab-badge {
background: rgba(14, 165, 233, 0.2);
color: #7dd3fc;
animation: none;
}
.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; }
/* Rich tabs (Wereldexport) */
.vtab-btn.vtab-btn-rich {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
}
.ei-vtab-text { min-width: 0; }
.ei-vtab-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: inherit;
line-height: 1.2;
}
.ei-vtab-sub {
display: block;
font-size: 0.75rem;
color: #64748b;
margin-top: 0.1rem;
}
.ei-vtab-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
}
.ei-vtab-key {
font-size: 0.6875rem;
color: #475569;
font-family: ui-monospace, monospace;
}
.vtabs-content { min-width: 0; }
.vt-tab-panel,
.ei-tab-panel {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: var(--vt-panel-bg);
overflow: hidden;
}
.vt-tab-panel-head,
.ei-tab-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(15, 23, 42, 0.4);
}
.vt-tab-panel-head h2,
.ei-tab-panel-head h2 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: #e2e8f0;
}
.vt-tab-panel-head p,
.ei-tab-panel-head p {
margin: 0.1rem 0 0;
font-size: 0.8125rem;
color: #64748b;
}
.vt-tab-panel-body,
.ei-tab-panel-body { padding: 1rem; }
/* Retail side-nav */
.side-nav {
position: sticky;
top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.65rem;
border-radius: 12px;
background: rgba(10, 14, 20, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.side-nav-btn {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.55rem 0.6rem;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 0.8rem;
font-weight: 500;
text-align: left;
cursor: pointer;
text-decoration: none;
}
.side-nav-btn:hover { background: rgba(30, 41, 59, 0.5); color: #e2e8f0; }
.side-nav-btn.active {
background: rgba(0, 102, 204, 0.1);
border-color: rgba(0, 102, 204, 0.3);
color: #fff;
}
.app-main { min-width: 0; }
.app-topbar {
display: flex; justify-content: space-between; align-items: center;
padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(8, 12, 18, 0.6);
}
.app-clock {
font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #94a3b8;
}
.app-clock strong { color: #e2e8f0; }
.main-topnav { padding: 1rem 1.25rem; }
/* Mobile layout → see mobile.css */
@media (max-width: 639px) {
.vtabs-nav {
position: relative;
top: 0;
flex-direction: row;
overflow-x: auto;
flex-wrap: nowrap;
padding: 0.45rem;
}
.vtabs-nav::before { display: none; }
.vtabs-nav-header { display: none; }
.vtab-btn { flex: 0 0 auto; min-width: 64px; }
.vtab-btn.vtab-btn-rich .ei-vtab-text,
.vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
}
+179
View File
@@ -0,0 +1,179 @@
.voice-live-page { --vl-accent: #a78bfa; max-width: 1400px; }
.voice-live-grid {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 1rem;
min-height: 520px;
}
@media (max-width: 1100px) {
.voice-live-grid { grid-template-columns: 1fr; }
}
.voice-panel {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(10, 14, 20, 0.9);
padding: 1rem;
display: flex;
flex-direction: column;
min-height: 0;
}
.voice-mic-zone { align-items: center; text-align: center; }
.voice-mic-btn {
width: 120px;
height: 120px;
border-radius: 50%;
border: 3px solid rgba(167, 139, 250, 0.45);
background: radial-gradient(circle at 30% 30%, rgba(167, 139, 250, 0.25), rgba(15, 20, 25, 0.95));
color: #f8fafc;
font-size: 2.5rem;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
margin: 0.5rem auto 1rem;
}
.voice-mic-btn:hover { border-color: rgba(167, 139, 250, 0.8); transform: scale(1.03); }
.voice-mic-btn.is-listening {
border-color: #f87171;
box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.2);
animation: vl-pulse 1.2s ease-in-out infinite;
}
.voice-mic-btn.is-busy {
border-color: #38bdf8;
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.15);
}
.voice-mic-btn.is-handsfree {
border-color: #34d399;
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
animation: vl-pulse-hf 2s ease-in-out infinite;
}
@keyframes vl-pulse-hf {
0%, 100% { box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12); }
50% { box-shadow: 0 0 0 12px rgba(52, 211, 153, 0.22); }
}
@keyframes vl-pulse {
0%, 100% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0.15); }
50% { box-shadow: 0 0 0 14px rgba(248, 113, 113, 0.28); }
}
.voice-status {
font-size: 0.82rem;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 0.35rem;
}
.voice-status-sub { font-size: 0.72rem; color: #64748b; margin-bottom: 0.75rem; }
.voice-live-preview {
width: 100%;
min-height: 3.5rem;
padding: 0.65rem 0.75rem;
border-radius: 10px;
background: rgba(15, 20, 25, 0.85);
border: 1px dashed rgba(167, 139, 250, 0.35);
font-size: 0.85rem;
color: #cbd5e1;
text-align: left;
line-height: 1.4;
}
.voice-live-preview.is-interim { color: #94a3b8; font-style: italic; }
.voice-live-preview strong { color: #a78bfa; font-size: 0.68rem; text-transform: uppercase; display: block; margin-bottom: 0.25rem; }
.voice-toggles { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 1rem; text-align: left; }
.voice-toggles label { font-size: 0.78rem; color: #94a3b8; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.voice-chat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.voice-chat-head h2 { margin: 0; font-size: 0.95rem; color: #f8fafc; }
.voice-chat-log {
flex: 1;
overflow-y: auto;
max-height: 480px;
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-right: 0.25rem;
}
.voice-bubble {
max-width: 92%;
padding: 0.65rem 0.85rem;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.45;
}
.voice-bubble-user {
align-self: flex-end;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.3);
}
.voice-bubble-agent {
align-self: flex-start;
background: rgba(167, 139, 250, 0.12);
border: 1px solid rgba(167, 139, 250, 0.28);
}
.voice-bubble-meta { font-size: 0.68rem; color: #64748b; margin-bottom: 0.25rem; }
.voice-bubble-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd;
margin-bottom: 0.35rem;
}
.voice-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.voice-side-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin: 0 0 0.5rem; }
.voice-pipeline { list-style: none; margin: 0 0 1rem; padding: 0; max-height: 220px; overflow-y: auto; }
.voice-pipeline li {
display: grid;
grid-template-columns: 72px 1fr;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.voice-pipeline .vl-phase {
font-size: 0.62rem;
text-transform: uppercase;
color: #64748b;
font-weight: 700;
}
.voice-pipeline .vl-label { color: #e2e8f0; font-weight: 600; }
.voice-pipeline .vl-detail { grid-column: 2; color: #94a3b8; font-size: 0.72rem; }
.voice-pipeline li.is-running .vl-label { color: #38bdf8; }
.voice-pipeline li.is-error .vl-label { color: #f87171; }
.voice-feed { max-height: 200px; overflow-y: auto; font-size: 0.72rem; }
.voice-feed-item { padding: 0.35rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); color: #94a3b8; }
.voice-feed-item strong { color: #c4b5fd; }
.voice-feed-item .vl-ts { color: #64748b; margin-right: 0.35rem; }
.voice-manual-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.voice-manual-row .form-input { flex: 1; font-size: 0.82rem; }
.voice-confirm-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 12px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.35);
}
.voice-confirm-text { font-size: 0.82rem; color: #fde68a; }
.voice-confirm-text strong { display: block; color: #fbbf24; font-size: 0.72rem; text-transform: uppercase; margin-bottom: 0.2rem; }
.voice-confirm-actions { display: flex; gap: 0.5rem; }
.voice-results-modal { z-index: 1400; }
.voice-results-card { width: min(720px, 96vw); max-height: 85vh; display: flex; flex-direction: column; }
.voice-results-table-wrap { overflow: auto; max-height: 50vh; margin: 0.5rem 0 1rem; border: 1px solid rgba(148,163,184,0.12); border-radius: 8px; }
.voice-results-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.voice-results-table th { text-align: left; padding: 0.5rem 0.65rem; background: rgba(15,20,25,0.9); color: #64748b; font-size: 0.65rem; text-transform: uppercase; position: sticky; top: 0; }
.voice-results-table td { padding: 0.45rem 0.65rem; border-top: 1px solid rgba(148,163,184,0.08); color: #cbd5e1; }
.voice-results-table a { color: #38bdf8; text-decoration: none; }
.voice-results-table a:hover { text-decoration: underline; }
+12 -1
View File
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overview",
"revenue_cockpit": "Revenue Cockpit",
"cto_home": "CTO Overview",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Clients",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "New client",
"tab_kanban": "Kanban",
"tab_list": "List",
"tab_stores": "Supermarkets"
"tab_stores": "Supermarkets",
"tab_360": "360° Client"
},
"products": {
"title": "Product catalog",
+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"
}
]
}
+12 -1
View File
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overzicht",
"revenue_cockpit": "Revenue Cockpit",
"cto_home": "CTO Overzicht",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Klanten",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "Nieuwe klant",
"tab_kanban": "Kanban",
"tab_list": "Lijst",
"tab_stores": "Supermarkten"
"tab_stores": "Supermarkten",
"tab_360": "360° Klant"
},
"products": {
"title": "Productcatalogus",
+1
View File
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
};
function cfg(key, fallbackColor) {
+756 -83
View File
@@ -1,9 +1,149 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
const TIER_ROWS = [4, 5, 7];
const MIN_NODE_GAP = 148;
const VIEW_W = 1200;
const VIEW_H = 860;
function make(tag, attrs = {}) {
/** Korte rol-context per agent (NL) */
const AGENT_HINTS = {
marketing: { short: 'Campagnes', domain: 'Groei' },
retail: { short: 'Supermarkten', domain: 'Groei' },
bizdev: { short: 'Deals', domain: 'Groei' },
research: { short: 'Marktintel', domain: 'Groei' },
browser: { short: 'Web research', domain: 'Groei' },
webbuilder: { short: 'Websites', domain: 'Product' },
product: { short: 'Assortiment', domain: 'Product' },
packaging: { short: 'Verpakking', domain: 'Product' },
design: { short: 'Visuals', domain: 'Product' },
halal: { short: 'Compliance', domain: 'Product' },
sourcing: { short: 'Inkoop', domain: 'Product' },
finance: { short: 'Cijfers', domain: 'Operatie' },
hr: { short: 'Team', domain: 'Operatie' },
knowledge: { short: 'Kennisbank', domain: 'Operatie' },
email: { short: 'Inbox', domain: 'Operatie' },
sysops: { short: 'Infra', domain: 'Operatie' },
};
const CLUSTERS = {
growth: {
label: 'Groei',
icon: '📈',
color: 'rgba(0, 229, 255, 0.14)',
stroke: 'rgba(0, 229, 255, 0.35)',
agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
},
product: {
label: 'Product',
icon: '📦',
color: 'rgba(168, 85, 247, 0.12)',
stroke: 'rgba(168, 85, 247, 0.35)',
agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
},
ops: {
label: 'Operatie',
icon: '⚙️',
color: 'rgba(34, 197, 94, 0.1)',
stroke: 'rgba(34, 197, 94, 0.32)',
agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
},
};
/** @type {Record<string, object>} */
const COMM_MODES = {
pyramid: {
id: 'pyramid',
label: 'Piramide',
subtitle: 'Herman als hub',
icon: '◆',
desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.42,
},
mesh: {
id: 'mesh',
label: 'Vol netwerk',
subtitle: 'Alle partners',
icon: '⬡',
desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.35,
edgeBend: 0.38,
},
org: {
id: 'org',
label: 'Organigram',
subtitle: 'Pure hiërarchie',
icon: '▤',
desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: false,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.12,
straightExec: true,
},
pulse: {
id: 'pulse',
label: 'Live stream',
subtitle: 'Alleen actief',
icon: '◎',
desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: true,
edgeBend: 0.45,
},
clusters: {
id: 'clusters',
label: 'Domeinen',
subtitle: 'Per afdeling',
icon: '◉',
desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
layout: 'clusters',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.2,
edgeBend: 0.5,
intraClusterOnly: false,
},
};
const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
let cachedData = null;
let pickerBound = false;
let onModeChangeCb = null;
let lastMountOpts = {};
function make(tag, attrs) {
attrs = attrs || {};
const el = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
Object.entries(attrs).forEach(function (pair) {
el.setAttribute(pair[0], String(pair[1]));
});
return el;
}
@@ -11,9 +151,234 @@
while (el.firstChild) el.removeChild(el.firstChild);
}
function linePath(x1, y1, x2, y2) {
const midY = y1 + (y2 - y1) * 0.45;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
function curvePath(x1, y1, x2, y2, bend) {
if (bend != null && bend < 0.18) {
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
}
const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
}
function clusterOf(key) {
const k = (key || '').toLowerCase();
return (
Object.keys(CLUSTERS).find(function (cid) {
return CLUSTERS[cid].agents.indexOf(k) >= 0;
}) || null
);
}
function spreadTier(nodes, width, minGap) {
if (nodes.length <= 1) return;
nodes.sort(function (a, b) {
return a._x - b._x;
});
for (let i = 1; i < nodes.length; i++) {
const gap = nodes[i]._x - nodes[i - 1]._x;
if (gap < minGap) {
const shift = minGap - gap;
for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
}
}
const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
const offset = width / 2 - tierCx;
nodes.forEach(function (n) {
n._x += offset;
});
}
function deoverlapLayout(souls, width) {
const tiers = {};
souls.forEach(function (n) {
const y = n._y || 0;
if (!tiers[y]) tiers[y] = [];
tiers[y].push(n);
});
Object.keys(tiers).forEach(function (y) {
spreadTier(tiers[y], width, MIN_NODE_GAP);
});
}
function agentSubtitle(node) {
const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
const task = (node.current_task || node.last_event_title || '').trim();
if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
if (hint) return hint.short;
return (node.role_title || '').split('·')[0].trim().slice(0, 24);
}
function agentStatus(node) {
if (node.is_active) return 'live';
const h = node.health || 'idle';
if (h === 'warn') return 'warn';
if (h === 'healthy') return 'ready';
return 'idle';
}
function addRotateAnim(group, dur) {
const anim = document.createElementNS(NS, 'animateTransform');
anim.setAttribute('attributeName', 'transform');
anim.setAttribute('type', 'rotate');
anim.setAttribute('from', '0 0 0');
anim.setAttribute('to', '360 0 0');
anim.setAttribute('dur', dur || '12s');
anim.setAttribute('repeatCount', 'indefinite');
group.appendChild(anim);
}
function layoutPyramid(souls, width, positions) {
const cx = width / 2;
const hermanY = positions.hermanY;
const execY = positions.execY;
const tierYs = positions.tierYs;
const minGap = MIN_NODE_GAP;
let idx = 0;
TIER_ROWS.forEach(function (count, tierIdx) {
const y = tierYs[tierIdx] || 580;
const slice = souls.slice(idx, idx + count);
idx += count;
const n = slice.length;
if (!n) return;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
slice.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._tier = tierIdx;
node._cluster = clusterOf(node.agent_key);
});
});
while (idx < souls.length) {
const y = 660;
const rest = souls.slice(idx);
const n = rest.length;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
rest.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._cluster = clusterOf(node.agent_key);
});
idx = souls.length;
}
deoverlapLayout(souls, width);
return { cx: cx, hermanY: hermanY, execY: execY };
}
function layoutClusters(souls, width, height) {
const cx = width / 2;
const hermanY = height * 0.42;
const execY = 58;
const clusterCenters = {
growth: { x: cx - 280, y: height * 0.72, r: 118 },
product: { x: cx, y: height * 0.78, r: 118 },
ops: { x: cx + 280, y: height * 0.72, r: 118 },
};
souls.forEach(function (node) {
const key = (node.agent_key || '').toLowerCase();
const cid = clusterOf(key) || 'product';
const center = clusterCenters[cid];
const members = CLUSTERS[cid].agents;
const idx = members.indexOf(key);
const n = members.length;
const angleStart = -Math.PI * 0.72;
const angleEnd = -Math.PI * 0.28;
const angle =
n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
const dist = center.r * 0.72;
node._x = center.x + Math.cos(angle) * dist;
node._y = center.y + Math.sin(angle) * dist * 0.55;
node._cluster = cid;
node._clusterCx = center.x;
node._clusterCy = center.y;
});
return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
}
function isSvgVisible(svg) {
if (!svg) return false;
const r = svg.getBoundingClientRect();
return r.width > 8 && r.height > 8;
}
function showMeshLoading(svg, text) {
clear(svg);
const g = make('g', { class: 'mesh-loading' });
const t = make('text', {
x: 600,
y: 360,
'text-anchor': 'middle',
fill: '#94a3b8',
'font-size': 14,
});
t.textContent = text || 'Netwerk laden…';
g.appendChild(t);
svg.appendChild(g);
}
function showMeshError(svg, text) {
clear(svg);
const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
t.textContent = text || 'Mesh kon niet geladen worden';
svg.appendChild(t);
}
function whenSvgVisible(svg, fn) {
if (!svg) return;
if (isSvgVisible(svg)) {
fn();
return;
}
let tries = 0;
const tick = function () {
tries += 1;
if (isSvgVisible(svg)) {
fn();
return;
}
if (tries < 60) {
requestAnimationFrame(tick);
} else {
fn();
}
};
requestAnimationFrame(tick);
}
function watchSvgResize(svg, renderFn) {
if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
if (!wrap) return;
let hadSize = isSvgVisible(svg);
svg._meshResizeObs = new ResizeObserver(function () {
const vis = isSvgVisible(svg);
if (vis && !hadSize) {
hadSize = true;
renderFn();
} else if (vis) {
hadSize = true;
}
});
svg._meshResizeObs.observe(wrap);
}
function renderMesh(svg, opts) {
if (!svg || !cachedData) return;
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
commMode: (opts && opts.commMode) || getSavedMode(),
});
const doDraw = function () {
try {
drawMesh(svg, cachedData, drawOpts);
} catch (e) {
console.error('drawMesh failed', e);
showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
}
};
doDraw();
watchSvgResize(svg, doDraw);
}
async function loadMeshData() {
@@ -22,67 +387,209 @@
return r.json();
}
function drawMesh(svg, data) {
async function ensureMeshData() {
if (cachedData) return cachedData;
cachedData = await loadMeshData();
return cachedData;
}
let pendingRender = false;
function scheduleRender(opts) {
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return;
if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
if (pendingRender) return;
pendingRender = true;
whenSvgVisible(svg, function () {
pendingRender = false;
renderMesh(svg, opts);
});
}
function nodePos(nodesByKey, key) {
const n = nodesByKey[key];
if (n && n._x != null) return { x: n._x, y: n._y };
return null;
}
function resolveMode(opts) {
const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] || COMM_MODES.pyramid;
}
function drawMesh(svg, data, opts) {
opts = opts || {};
const mode = resolveMode(opts);
const onNodeClick = opts.onNodeClick;
clear(svg);
svg.setAttribute('data-mesh-mode', mode.id);
const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1100;
const height = vb && vb.height ? vb.height : 640;
const width = vb && vb.width ? vb.width : VIEW_W;
const height = vb && vb.height ? vb.height : VIEW_H;
if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
}
const cx = width / 2;
const hermanY = 280;
const execY = 75;
const agentY = 520;
const positions = {
execY: 72,
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
tierYs: [400, 540, 690],
};
const souls = (data.nodes || []).filter((n) => {
const souls = (data.nodes || []).filter(function (n) {
const key = (n.agent_key || '').toLowerCase();
return key && key !== 'herman';
});
const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
const layoutMeta =
mode.layout === 'clusters'
? layoutClusters(souls, width, height)
: layoutPyramid(souls, width, positions);
const agentCount = Math.max(1, souls.length);
const pad = 70;
const span = width - pad * 2;
souls.forEach((node, i) => {
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
if (agentCount === 1) node._x = cx;
node._y = agentY;
const cx = layoutMeta.cx;
const hermanY = layoutMeta.hermanY;
const execY = layoutMeta.execY;
const nodesByKey = {};
souls.forEach(function (n) {
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
});
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
const edgesLayer = make('g');
const nodesLayer = make('g');
const bgLayer = make('g', { class: 'mesh-bg' });
const clusterLayer = make('g', { class: 'mesh-clusters' });
const edgesLayer = make('g', { class: 'mesh-edges' });
const nodesLayer = make('g', { class: 'mesh-nodes' });
svg.appendChild(bgLayer);
if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
function addEdge(x1, y1, x2, y2, pulse) {
const d = linePath(x1, y1, x2, y2);
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
if (mode.layout === 'pyramid') {
positions.tierYs.forEach(function (y, i) {
bgLayer.appendChild(
make('rect', {
x: 40,
y: y - 42,
width: width - 80,
height: 84,
rx: 12,
class: 'mesh-tier-band mesh-tier-' + i,
})
);
});
}
if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
Object.keys(CLUSTERS).forEach(function (cid) {
const c = CLUSTERS[cid];
const center = layoutMeta.clusterCenters[cid];
if (!center) return;
const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
g.appendChild(
make('ellipse', {
cx: center.x,
cy: center.y,
rx: center.r + 28,
ry: center.r * 0.62,
fill: c.color,
stroke: c.stroke,
'stroke-width': 1.2,
})
);
const lbl = make('text', {
x: center.x,
y: center.y - center.r * 0.35,
class: 'mesh-cluster-label',
'text-anchor': 'middle',
});
lbl.textContent = c.icon + ' ' + c.label;
g.appendChild(lbl);
clusterLayer.appendChild(g);
});
}
function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
const attrs = { d: d, class: 'mesh-edge ' + cls };
if (extraAttrs) Object.assign(attrs, extraAttrs);
edgesLayer.appendChild(make('path', attrs));
if (pulse) {
const p = make('path', { d, class: 'mesh-pulse' });
p.style.animationDuration = pulse + 's';
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
edgesLayer.appendChild(p);
}
}
souls.forEach((node) => {
const weight = edgesBySource[node.agent_key]?.weight || 1;
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
if (mode.showExecutive) {
const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
}
(data.peer_edges || []).forEach(function (e) {
const live = e.type === 'live';
if (live && !mode.showLivePeers) return;
if (!live && !mode.showStaticPeers) return;
if (mode.activeOnly && !live) return;
const a = nodePos(nodesByKey, e.source);
const b = nodePos(nodesByKey, e.target);
if (!a || !b) return;
const srcNode = nodesByKey[e.source];
const tgtNode = nodesByKey[e.target];
const sameCluster =
srcNode &&
tgtNode &&
srcNode._cluster &&
srcNode._cluster === tgtNode._cluster;
if (mode.intraClusterOnly && !sameCluster) return;
const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
const pulse = live ? 2.2 : false;
const extra = {};
if (!live && mode.staticPeerOpacity != null) {
extra['stroke-opacity'] = mode.staticPeerOpacity;
}
if (sameCluster && mode.layout === 'clusters') {
extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
}
addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
});
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
if (mode.showReport) {
(data.report_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const a = nodePos(nodesByKey, e.source);
if (!a) return;
addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
});
}
if (mode.showDelegate) {
(data.delegate_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const b = nodePos(nodesByKey, e.target);
if (!b) return;
addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
});
}
function drawExec(node, cls) {
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
g.lastChild.textContent = node.label;
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
t.textContent = node.label;
g.appendChild(t);
const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
sub.textContent = node.sub;
g.appendChild(sub);
nodesLayer.appendChild(g);
@@ -91,56 +598,222 @@
drawExec(ceo, 'mesh-ceo');
drawExec(cto, 'mesh-cto');
const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
crown.textContent = '👑';
herman.appendChild(crown);
const hLabel = make('text', { x: cx, y: hermanY + 18 });
hLabel.textContent = 'Herman · Co-CEO';
herman.appendChild(hLabel);
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
hSub.textContent = 'Takenverdeler';
herman.appendChild(hSub);
nodesLayer.appendChild(herman);
const hermanActive =
(data.delegate_edges || []).some(function (e) {
return e.active;
}) ||
(data.report_edges || []).some(function (e) {
return e.active;
});
souls.forEach((node) => {
const hermanG = make('g', {
class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''),
transform: 'translate(' + cx + ',' + hermanY + ')',
});
if (hermanActive) {
const ringG = make('g', { class: 'mesh-herman-ring' });
ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 }));
addRotateAnim(ringG, '14s');
hermanG.appendChild(ringG);
}
hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 }));
const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' });
crown.textContent = '👑';
hermanG.appendChild(crown);
const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' });
hLabel.textContent = 'Herman';
hermanG.appendChild(hLabel);
const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' });
hSub.textContent = 'Co-CEO · takenverdeler';
hermanG.appendChild(hSub);
const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' });
const hermanNode = (data.nodes || []).find(function (n) {
return (n.agent_key || '').toLowerCase() === 'herman';
});
hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents';
hermanG.appendChild(hTask);
nodesLayer.appendChild(hermanG);
souls.forEach(function (node, idx) {
const key = (node.agent_key || '').toLowerCase();
const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName;
const live = !!node.is_active;
const status = agentStatus(node);
const hint = AGENT_HINTS[key];
const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : '';
const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1;
const group = make('g', {
class:
'mesh-node mesh-agent mesh-status-' +
status +
' ' +
(node.health || 'idle') +
(live ? ' mesh-agent-live' : '') +
clusterCls,
transform: 'translate(' + node._x + ',' + node._y + ')',
});
if (live) {
const pulseG = make('g', { class: 'mesh-pulse-wrap' });
pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' }));
group.appendChild(pulseG);
}
group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' }));
const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' });
emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●';
group.appendChild(emoji);
const displayName = (node.display_name || key || '?').split(' ')[0];
const nameY = labelAbove ? -30 : 36;
const ctxY = labelAbove ? -16 : 52;
const domY = labelAbove ? -2 : 66;
const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' });
t.textContent = displayName;
group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
group.appendChild(role);
const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
status.textContent = (node.health || 'idle').toUpperCase();
group.appendChild(status);
group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) {
Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
}
const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
ctx.textContent = agentSubtitle(node);
group.appendChild(ctx);
if (hint && mode.layout !== 'clusters') {
const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
dom.textContent = hint.domain;
group.appendChild(dom);
}
const tip =
(node.role_title || '') +
(node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
(node.current_task ? '\nNu: ' + node.current_task : '');
group.setAttribute('title', tip.slice(0, 200));
group.addEventListener('click', function () {
if (onNodeClick) onNodeClick(node);
});
nodesLayer.appendChild(group);
});
}
async function mount(targetId) {
const svg = document.getElementById(targetId);
if (!svg) return;
try {
const data = await loadMeshData();
drawMesh(svg, data);
} catch (e) {
clear(svg);
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
msg.textContent = 'Mesh kon niet geladen worden';
svg.appendChild(msg);
function updatePickerUI(modeId) {
document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
const active = btn.getAttribute('data-mesh-mode') === modeId;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
const wrap = document.querySelector('.mesh-wrap');
if (wrap) {
wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
wrap.setAttribute('data-mode', modeId);
}
const badge = document.getElementById('mesh-mode-badge');
if (badge && COMM_MODES[modeId]) {
badge.textContent = COMM_MODES[modeId].label;
}
}
window.AgentsMesh = { mount };
function pickMode(modeId, opts, silent) {
const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
if (!silent) saveMode(mode);
updatePickerUI(mode);
if (onModeChangeCb) onModeChangeCb(mode);
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return mode;
showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
ensureMeshData()
.then(function () {
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
})
.catch(function (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
});
return mode;
}
function bindModePicker(opts) {
lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
if (pickerBound) return;
pickerBound = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-mesh-mode]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = btn.getAttribute('data-mesh-mode');
pickMode(id);
});
}
function setModeChangeCallback(fn) {
onModeChangeCb = fn;
}
async function mount(targetId, opts) {
opts = opts || {};
lastMountOpts = opts;
const svg = document.getElementById(targetId);
if (!svg) return null;
bindModePicker(opts);
const modeObj = resolveMode(opts);
updatePickerUI(modeObj.id);
showMeshLoading(svg, 'Netwerk laden…');
try {
await ensureMeshData();
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
return cachedData;
} catch (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
return null;
}
}
async function refresh(targetId, opts) {
cachedData = null;
return mount(targetId, opts);
}
function redraw(targetId, opts) {
const svg = document.getElementById(targetId);
if (!svg || !cachedData) return;
whenSvgVisible(svg, function () {
renderMesh(svg, opts || {});
});
}
function saveMode(modeId) {
if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
}
function getSavedMode() {
const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] ? id : 'pyramid';
}
window.AgentsMesh = {
mount,
refresh,
redraw,
pickMode,
bindModePicker,
setModeChangeCallback,
loadMeshData,
drawMesh,
COMM_MODES,
CLUSTERS,
saveMode,
getSavedMode,
};
function bootPicker() {
bindModePicker({});
const saved = getSavedMode();
updatePickerUI(saved);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootPicker);
} else {
bootPicker();
}
if (window.location.pathname.indexOf('/agents') >= 0) {
ensureMeshData().catch(function () {});
}
})();
+244
View File
@@ -0,0 +1,244 @@
(function () {
const buffers = {};
let liveEs = null;
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function nowIso() {
return new Date().toISOString();
}
function lineClass(type) {
if (!type) return 'line-action';
if (type === 'command') return 'line-command';
if (type === 'output') return 'line-output';
if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
if (type === 'error') return 'line-error';
if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
if (String(type).indexOf('browse') >= 0) return 'line-action';
return 'line-action';
}
function formatLine(entry) {
const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
const msg = entry.message || entry.title || '';
const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
return (
'<div class="term-line ' +
lineClass(entry.type) +
'"><span class="term-ts">' +
esc(t) +
'</span><span class="term-msg">' +
esc(msg + detail) +
'</span></div>'
);
}
function render(key) {
const els = document.querySelectorAll('[data-term-key="' + key + '"]');
if (!els.length) return;
const lines = buffers[key] || [];
const html =
!lines.length
? '<div class="term-line term-idle"><span class="term-msg">Typ <code>help</code> voor commando\'s · Enter om uit te voeren</span></div>'
: lines.slice(-80).map(formatLine).join('');
els.forEach(function (el) {
el.innerHTML = html;
el.scrollTop = el.scrollHeight;
const win = el.closest('.agent-term-window');
if (win && lines.length) win.classList.add('is-live');
});
}
function pushLine(key, entry, skipDup) {
if (!key || !entry) return;
key = String(key).toLowerCase();
if (!buffers[key]) buffers[key] = [];
if (skipDup && entry._id) {
const seen = buffers[key].some(function (l) {
return l._id === entry._id;
});
if (seen) return;
}
if (!entry.at) entry.at = nowIso();
buffers[key].push(entry);
if (buffers[key].length > 120) buffers[key].shift();
render(key);
}
function setBusy(key, busy) {
document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
input.disabled = !!busy;
input.classList.toggle('is-busy', !!busy);
});
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
const win = body.closest('.agent-term-window');
if (win) win.classList.toggle('is-running', !!busy);
});
}
async function runCommand(key, cmd) {
key = String(key || '').toLowerCase();
cmd = (cmd || '').trim();
if (!cmd) return;
if (cmd.toLowerCase() === 'clear') {
buffers[key] = [];
render(key);
return;
}
pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
setBusy(key, true);
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd }),
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
const err =
(typeof data.detail === 'string' && data.detail) ||
(Array.isArray(data.detail) && data.detail[0]?.msg) ||
'Commando mislukt';
pushLine(key, { type: 'error', message: err, at: nowIso() });
return;
}
(data.lines || []).forEach(function (line) {
pushLine(key, {
type: line.type || 'output',
message: line.message || '',
detail: line.detail || '',
at: nowIso(),
});
});
} catch (e) {
pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
} finally {
setBusy(key, false);
}
}
function bindInputs() {
document.querySelectorAll('[data-term-input]').forEach(function (input) {
if (input._termBound) return;
input._termBound = true;
const key = input.getAttribute('data-term-input');
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const val = input.value;
input.value = '';
runCommand(key, val);
}
});
input.addEventListener('click', function (e) {
e.stopPropagation();
});
});
}
async function loadHistory(key) {
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
if (!r.ok) return;
const data = await r.json();
buffers[key] = (data.items || []).map(function (ev) {
const meta = ev.metadata || {};
let type = 'action';
const et = ev.event_type || '';
if (et.indexOf('handoff') >= 0) type = 'handoff_out';
if (meta.target_agent) type = 'handoff_out';
if (meta.source_agent) type = 'handoff_in';
if (et.indexOf('monitor') >= 0) type = 'monitor';
if (et.indexOf('browse') >= 0) type = 'browse';
if (et === 'terminal_in') type = 'command';
if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
return {
_id: ev.id,
type: type,
message: ev.title || ev.event_type,
detail: ev.body,
at: ev.created_at,
};
});
render(key);
} catch (e) {}
}
function handleLivePayload(d) {
if (!d || d.type === 'connected' || d.type === 'error') return;
const key = (d.agent || '').toLowerCase();
if (!key) return;
pushLine(
key,
{
_id: d.id,
type: d.type,
message: d.message,
detail: d.detail,
at: d.at,
},
true
);
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
const win = el.closest('.agent-term-window');
if (win) win.classList.add('is-live');
});
}
function startLiveStream() {
if (liveEs || typeof EventSource === 'undefined') return;
liveEs = new EventSource('/api/agents/live/stream');
liveEs.onmessage = function (ev) {
try {
handleLivePayload(JSON.parse(ev.data));
} catch (e) {}
};
liveEs.onerror = function () {
/* EventSource auto-reconnects */
};
}
function stopLiveStream() {
if (liveEs) {
try {
liveEs.close();
} catch (e) {}
liveEs = null;
}
}
async function mountAll() {
const nodes = document.querySelectorAll('[data-term-key]');
const keys = [];
nodes.forEach(function (n) {
const k = n.getAttribute('data-term-key');
if (k && keys.indexOf(k) < 0) keys.push(k);
});
await Promise.all(keys.map(loadHistory));
bindInputs();
startLiveStream();
}
function stopAll() {
stopLiveStream();
}
window.AgentsTerminals = {
mountAll,
stopAll,
pushLine,
render,
runCommand,
bindInputs,
startLiveStream,
stopLiveStream,
};
})();
File diff suppressed because one or more lines are too long
+209 -36
View File
@@ -1,6 +1,8 @@
window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
var typewriterAbort = null;
var lastDigestSig = '';
function destroyAll() {
Object.keys(charts).forEach(function (k) {
@@ -174,19 +176,15 @@ window.BriefingCharts = (function () {
}).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;
var items = stats.food_market_highlights || stats.rss_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen highlights — <a href="/marketing">RSS ophalen</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = (r.category || 'markt').toUpperCase();
return '<div class="hm-highlight-item"><span class="hm-cat-badge">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
@@ -332,17 +330,7 @@ window.BriefingCharts = (function () {
function renderRetail(container, stats) {
if (!container) return;
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var when = (r.published_at || '').substring(0, 10);
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
@@ -369,19 +357,15 @@ window.BriefingCharts = (function () {
{ 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 || []).slice(0, 5);
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 (trending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
trending.forEach(function (r) {
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
});
html += '</ul>';
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">';
@@ -411,19 +395,208 @@ window.BriefingCharts = (function () {
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 sumEl = document.getElementById('briefing-summary-text');
if (sumEl && parsed.summary) {
var lines = parsed.summary.split(/(?<=[.!?])\s+/).filter(Boolean);
typewriterLiveDigest(sumEl, {
sections: [{ brand: 'platform', title: 'Herman rapport', icon: '📝', lines: lines.length ? lines : [parsed.summary] }],
}, { charMs: 10, linePause: 90 });
}
var actEl = document.getElementById('briefing-actions-list');
if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Genereer dagrapport voor actiepunten</li>';
if (actEl) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
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>';
if (longEl) setTimeout(function () {
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
}, 300);
}
function renderLive(root, stats, vizMode, kpiSelection) {
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function digestSignature(digest) {
if (!digest) return '';
return JSON.stringify({
sections: digest.sections || [],
actions: digest.actions || [],
long_term: digest.long_term || [],
});
}
function fillDigestInstant(container, digest) {
var sections = (digest && digest.sections) || [];
if (!sections.length) {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5>' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
}
function typewriterLiveDigest(container, digest, opts) {
if (!container) return;
opts = opts || {};
var charMs = opts.charMs || 14;
var linePause = opts.linePause || 120;
var sectionPause = opts.sectionPause || 200;
if (typewriterAbort) { typewriterAbort(); typewriterAbort = null; }
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
var sections = (digest && digest.sections) || [];
if (!sections.length) {
var summary = (digest && digest.summary) || '';
if (summary) {
typewriter(container, summary, charMs);
} else {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
}
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function () { return '<li class="tw-line"></li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5 class="tw-heading">' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
var queue = [];
container.querySelectorAll('.live-digest-section').forEach(function (secEl, si) {
var lines = (sections[si] && sections[si].lines) || [];
var lis = secEl.querySelectorAll('li.tw-line');
for (var i = 0; i < lis.length; i++) {
queue.push({ el: lis[i], text: lines[i] || '', section: si });
}
});
var cancelled = false;
typewriterAbort = function () { cancelled = true; };
container.classList.add('is-typing');
function tick(qi, ci, prevSection) {
if (cancelled) return;
if (qi >= queue.length) {
container.classList.remove('is-typing');
container.querySelectorAll('.is-typing').forEach(function (n) { n.classList.remove('is-typing'); });
return;
}
var item = queue[qi];
var el = item.el;
var text = item.text;
if (prevSection !== item.section && ci === 0) {
setTimeout(function () { if (!cancelled) tick(qi, ci, item.section); }, sectionPause);
return;
}
el.classList.add('is-typing');
container.querySelectorAll('li.is-typing').forEach(function (n) { if (n !== el) n.classList.remove('is-typing'); });
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
setTimeout(function () { tick(qi, ci + 1, item.section); }, charMs);
} else {
el.classList.remove('is-typing');
setTimeout(function () { tick(qi + 1, 0, item.section); }, linePause);
}
}
tick(0, 0, -1);
}
function typewriterListItems(listEl, items, opts) {
if (!listEl || !items || !items.length) return;
opts = opts || {};
var charMs = opts.charMs || 10;
listEl.innerHTML = items.map(function () { return '<li class="tw-line"></li>'; }).join('');
var lis = listEl.querySelectorAll('li.tw-line');
var qi = 0, ci = 0, cancelled = false;
function next() {
if (cancelled || qi >= items.length) return;
var el = lis[qi];
var text = items[qi];
el.classList.add('is-typing');
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
ci++;
setTimeout(next, charMs);
} else {
el.classList.remove('is-typing');
qi++; ci = 0;
setTimeout(next, 90);
}
}
next();
return function () { cancelled = true; };
}
function fillListInstant(listEl, items) {
if (!listEl) return;
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
}
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var sig = digestSignature(digest);
var unchanged = pollRefresh && sig === lastDigestSig;
if (!unchanged) lastDigestSig = sig;
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
}
if (unchanged) {
if (actEl) fillListInstant(actEl, digest.actions || []);
if (longEl) fillListInstant(longEl, digest.long_term || []);
return;
}
var isUpdate = !!lastDigestSig && !pollRefresh;
var twOpts = { charMs: isUpdate ? 8 : 12, linePause: isUpdate ? 70 : 100, sectionPause: isUpdate ? 120 : 180 };
if (sumEl) {
if (pollRefresh) fillDigestInstant(sumEl, digest);
else typewriterLiveDigest(sumEl, digest, twOpts);
}
var actions = digest.actions || [];
if (actEl) {
if (pollRefresh) fillListInstant(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail']);
else typewriterListItems(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail'], { charMs: isUpdate ? 6 : 8 });
}
var lt = digest.long_term || [];
if (longEl) {
if (pollRefresh) fillListInstant(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen']);
else setTimeout(function () {
typewriterListItems(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen'], { charMs: isUpdate ? 6 : 8 });
}, isUpdate ? 200 : 400);
}
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
if (!root || !stats) return;
opts = opts || {};
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
@@ -451,7 +624,7 @@ window.BriefingCharts = (function () {
}
return {
render: render, renderLive: renderLive, renderText: renderText,
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
+3 -4
View File
@@ -3,7 +3,6 @@
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
rss: { title: 'RSS live feed', icon: '📰' },
retail: { title: 'Retail operatie', icon: '🏪' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
@@ -17,9 +16,9 @@
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/?tab=rss' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/?tab=rss' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing?tab=bookmarks' },
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
+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());
});
})();
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
/**
* Export Intel map — Leaflet + clustering + rich popups
*/
window.ExportIntelMap = (function () {
var map = null;
var clusterLayer = null;
var onEntityClick = null;
var halalMode = false;
var markersById = {};
var HALAL_COLORS = {
hot: '#22c55e',
warm: '#eab308',
mild: '#f97316',
low: '#64748b',
};
var TYPE_CFG = {
distributor: { color: '#f59e0b', icon: '📦', label: 'Distributeur' },
wholesaler: { color: '#eab308', icon: '🏪', label: 'Groothandel' },
importer: { color: '#f97316', icon: '🚢', label: 'Importeur' },
logistics: { color: '#38bdf8', icon: '🚚', label: 'Logistiek' },
contract_caterer:{ color: '#a78bfa', icon: '🏢', label: 'Cateraar' },
restaurant: { color: '#34d399', icon: '🍽️', label: 'Restaurant' },
doner_shoarma: { color: '#4ade80', icon: '🥙', label: 'Döner / shoarma' },
butcher: { color: '#f87171', icon: '🥩', label: 'Slager' },
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
};
function cfg(type) {
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function row(label, val, link) {
if (!val) return '';
var inner = link
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
: escapeHtml(val);
return (
'<div class="ei-popup-row">' +
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
'<span class="ei-popup-v">' + inner + '</span>' +
'</div>'
);
}
function halalColor(p) {
var tier = p.halal_tier || 'low';
return HALAL_COLORS[tier] || HALAL_COLORS.low;
}
function popupHtml(p) {
var c = cfg(p.entity_type);
var id = p.id;
var web = p.website;
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
var halalRow = '';
if (p.halal_score != null) {
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
}
var contactBits = [];
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
var contactRow = contactBits.length
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
: '';
return (
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
'<div class="ei-popup-top">' +
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
'</div>' +
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
'<div class="ei-popup-grid">' +
halalRow +
contactRow +
row('Stad', p.city) +
row('Adres', p.address_line) +
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
row('Website', p.website, web) +
row('Volume', p.volume_band) +
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
row('Pipeline', p.pipeline_stage) +
'</div>' +
'<div class="ei-popup-actions">' +
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
'</div>' +
'</div>'
);
}
function pinIcon(type, p) {
var c = cfg(type);
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
return L.divIcon({
className: 'ei-pin-wrap',
html:
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
'<span class="ei-pin-ring"></span>' +
'<span class="ei-pin-icon">' + c.icon + '</span>' +
'</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -16],
});
}
function openEntityId(rawId) {
var id = Number(rawId);
if (!Number.isFinite(id)) return;
if (onEntityClick) {
onEntityClick(id);
return;
}
try {
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
} catch (e) { /* empty */ }
}
function wirePopup(container) {
if (!container) return;
var popupRoot = container.closest('.leaflet-popup') || container;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(popupRoot);
L.DomEvent.disableScrollPropagation(popupRoot);
}
var btn = container.querySelector('.ei-popup-btn');
var id = btn && btn.getAttribute('data-entity-id');
if (btn && id) {
var onBtn = function (e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
openEntityId(id);
};
if (window.L && L.DomEvent) {
L.DomEvent.on(btn, 'click', onBtn);
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
} else {
btn.addEventListener('click', onBtn);
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
}
}
container.addEventListener('click', function (e) {
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
var card = e.target.closest('[data-entity-id]');
if (card) openEntityId(card.getAttribute('data-entity-id'));
});
}
function invalidateMapSize() {
if (!map) return;
map.invalidateSize(true);
setTimeout(function () { if (map) map.invalidateSize(true); }, 120);
setTimeout(function () { if (map) map.invalidateSize(true); }, 400);
setTimeout(function () { if (map) map.invalidateSize(true); }, 800);
}
function init(containerId) {
var el = document.getElementById(containerId);
if (!el || !window.L) return null;
if (map && map.getContainer() === el) {
invalidateMapSize();
return map;
}
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
L.tileLayer('/api/map/tiles/{z}/{x}/{y}.png', {
attribution: '&copy; OSM &copy; CARTO',
maxZoom: 19,
}).addTo(map);
if (window.L.markerClusterGroup) {
clusterLayer = L.markerClusterGroup({
maxClusterRadius: 42,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: function (cluster) {
var n = cluster.getChildCount();
var size = n < 10 ? 'sm' : n < 50 ? 'md' : 'lg';
return L.divIcon({
html: '<div class="ei-cluster ei-cluster-' + size + '"><span>' + n + '</span></div>',
className: 'ei-cluster-wrap',
iconSize: L.point(44, 44),
});
},
});
map.addLayer(clusterLayer);
} else {
clusterLayer = L.layerGroup();
map.addLayer(clusterLayer);
}
map.on('popupopen', function (e) {
var root = e.popup && e.popup.getElement();
if (!root) return;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(root);
L.DomEvent.disableScrollPropagation(root);
}
wirePopup(root.querySelector('.leaflet-popup-content') || root);
});
invalidateMapSize();
return map;
}
function setFeatures(geojson, opts) {
opts = opts || {};
if (!map || !clusterLayer) return;
clusterLayer.clearLayers();
markersById = {};
var features = (geojson && geojson.features) || [];
var markers = [];
features.forEach(function (f) {
var coords = f.geometry && f.geometry.coordinates;
if (!coords) return;
var p = f.properties || {};
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) });
marker._eiId = p.id;
if (p.id != null) markersById[p.id] = marker;
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' });
marker.on('dblclick', function () {
if (onEntityClick && p.id) onEntityClick(p.id);
});
markers.push(marker);
});
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
invalidateMapSize();
if (opts.autoFit === false) {
map.setView([20, 10], 2);
} else if (features.length === 1) {
var c = features[0].geometry.coordinates;
map.setView([c[1], c[0]], 10);
} else if (opts.autoFit && markers.length > 1 && clusterLayer.getBounds) {
try {
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
} catch (e) { /* empty */ }
}
}
function setHalalMode(on) {
halalMode = !!on;
}
function setOnEntityClick(fn) {
onEntityClick = fn;
}
function flyTo(lat, lon, zoom) {
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
}
function highlightEntity(id) {
var marker = markersById[id];
if (!marker || !map) return;
var latlng = marker.getLatLng();
map.setView(latlng, Math.max(map.getZoom(), 14));
marker.openPopup();
}
function closePopup() {
if (map) map.closePopup();
}
function destroy() {
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', function () { invalidateMapSize(); });
}
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
})();
+750
View File
@@ -0,0 +1,750 @@
function exportIntelApp() {
return {
tab: 'map',
tabIndicatorTop: 0,
tabIndicatorH: 40,
busy: false,
syncMsg: '',
stats: {},
regions: [],
territories: [],
country: '',
region: '',
entities: [],
contacts: [],
contactsHasMore: false,
contactsLoadingMore: false,
tenders: [],
catererPresence: [],
catererBrands: [],
govSources: [],
selected: null,
drawerOpen: false,
q: '',
entityTypeFilter: '',
mapTypesSelected: [],
mapHalalMode: false,
mapHalalMin: 55,
mapError: '',
mapLoading: false,
mapTruncated: false,
halalTopMarkets: [],
halalTopEntities: [],
listFocusId: null,
listFocusDetail: null,
listFocusLoading: false,
drawerLoading: false,
filterHasEmail: '',
filterFavoritesOnly: false,
filterCrmStatus: '',
selectedIds: [],
crmPipeline: [],
crmModalOpen: false,
crmCreateDeals: true,
crmPushBusy: false,
editMode: false,
editForm: {},
resultCount: 0,
_searchTimer: null,
tabs: [
{ id: 'map', icon: '🗺️', label: 'Kaart', color: '#0ea5e9', key: '1' },
{ id: 'distributors', icon: '📦', label: 'Distributeurs', color: '#fbbf24', key: '2' },
{ id: 'customers', icon: '🍽️', label: 'Eindklanten', color: '#34d399', key: '3' },
{ id: 'caterers', icon: '🏢', label: 'Cateraars', color: '#a78bfa', key: '4' },
{ id: 'contacts', icon: '📇', label: 'Contacten', color: '#38bdf8', key: '5' },
{ id: 'tenders', icon: '📋', label: 'Tenders', color: '#c084fc', key: '6' },
{ id: 'gov', icon: '🏛️', label: 'Overheid', color: '#94a3b8', key: '7' },
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
],
mapTypeChips: [
{ v: 'distributor', l: 'Distributeur', icon: '📦' },
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
{ v: 'importer', l: 'Importeur', icon: '🚢' },
{ v: 'logistics', l: 'Logistiek', icon: '🚚' },
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' },
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
{ v: 'butcher', l: 'Slager', icon: '🥩' },
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
{ v: 'foodservice', l: 'Foodservice', icon: '🍴' },
],
async init() {
var params = new URLSearchParams(location.search);
var t = params.get('tab');
if (t) this.tab = t;
var self = this;
document.addEventListener('export-intel:open-entity', function (ev) {
if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id);
});
await this.loadMeta();
await this.refresh();
this.$nextTick(() => this.updateTabIndicator());
window.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select')) return;
var n = parseInt(e.key, 10);
if (n >= 1 && n <= 8) {
var btn = this.$refs.vtabsNav?.querySelector('[data-tab="' + this.tabs[n - 1].id + '"]');
this.setTab(this.tabs[n - 1].id, btn);
}
});
},
async loadMeta() {
try {
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
} catch (e) {
console.error(e);
}
},
async refresh() {
this.busy = true;
try {
var cp = this.country ? '?country=' + encodeURIComponent(this.country) : '';
var rp = this.region ? (cp ? '&' : '?') + 'region=' + encodeURIComponent(this.region) : '';
this.stats = await fetch('/api/export-intel/stats' + cp + rp).then((r) => r.json());
await this.loadTabData();
if (this.tab === 'map') this.loadMap();
} catch (e) {
console.error(e);
}
this.busy = false;
},
filterQs() {
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
if (this.filterHasEmail === 'no') parts.push('has_email=false');
if (this.filterFavoritesOnly) parts.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
return parts.length ? '&' + parts.join('&') : '';
},
onSearchInput() {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.applyMapFilters(), 350);
},
clearFilters() {
this.q = '';
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
this.filterFavoritesOnly = false;
this.filterCrmStatus = '';
this.selectedIds = [];
this.applyMapFilters();
},
showEntityTypeFilter() {
return ['distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showMapTypeFilter() {
return this.tab === 'map';
},
isMapTypeActive(v) {
return this.mapTypesSelected.indexOf(v) >= 0;
},
toggleMapType(v) {
var i = this.mapTypesSelected.indexOf(v);
if (i >= 0) this.mapTypesSelected.splice(i, 1);
else this.mapTypesSelected.push(v);
this.entityTypeFilter = '';
this.applyMapFilters();
},
clearMapTypes() {
this.mapTypesSelected = [];
this.applyMapFilters();
},
mapTypesParam() {
if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter };
if (this.mapTypesSelected.length) {
return { entity_types: this.mapTypesSelected.join(',') };
}
return {};
},
applyMapFilters() {
if (this.tab !== 'map') {
this.loadTabData();
return;
}
this.loadTabData().then(() => this.loadMap());
},
showEmailFilter() {
return this.tab === 'contacts';
},
showCrmFilter() {
return this.tab === 'map' || this.tab === 'contacts';
},
showSelectionBar() {
return ['map', 'contacts', 'distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
},
isWorking() {
return this.busy || this.crmPushBusy || this.mapLoading || this.drawerLoading;
},
progressLabel() {
if (this.busy && this.syncMsg) return this.syncMsg;
if (this.crmPushBusy) return 'CRM import bezig…';
if (this.mapLoading) return 'Kaart laden…';
if (this.drawerLoading) return 'Profiel laden…';
return 'Bezig…';
},
entityTypeOptions() {
if (this.tab === 'distributors') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'importer', l: 'Importeur' },
{ v: 'logistics', l: 'Logistiek' },
];
}
if (this.tab === 'customers') {
return [
{ v: '', l: 'Alle types' },
{ v: 'restaurant', l: 'Restaurant' },
{ v: 'doner_shoarma', l: 'Döner / shoarma' },
{ v: 'butcher', l: 'Slager' },
{ v: 'foodservice', l: 'Foodservice' },
];
}
if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
}
if (this.tab === 'contacts') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'importer', l: 'Importeur' },
{ v: 'logistics', l: 'Logistiek' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
{ v: 'foodservice', l: 'Foodservice' },
];
}
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
];
},
async loadTabData(opts) {
opts = opts || {};
var base = '/api/export-intel';
var fq = this.filterQs();
if (this.tab === 'map') {
return;
} else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics';
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
this.entities = r.items || [];
this.resultCount = r.total != null ? r.total : this.entities.length;
} else if (this.tab === 'customers') {
var types2 = 'restaurant,doner_shoarma,butcher,foodservice';
var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json());
this.entities = r2.items || [];
this.resultCount = r2.total != null ? r2.total : this.entities.length;
} else if (this.tab === 'caterers') {
var et3 = this.entityTypeFilter || 'contract_caterer';
var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
this.entities = r3.items || [];
this.resultCount = r3.total != null ? r3.total : this.entities.length;
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
} else if (this.tab === 'contacts') {
if (!opts.append) {
this.contacts = [];
this.contactsHasMore = false;
}
var offset = opts.append ? (this.contacts || []).length : 0;
var r4 = await fetch(base + '/contacts?limit=200&offset=' + offset + fq).then((x) => x.json());
var items = r4.items || [];
if (opts.append) this.contacts = (this.contacts || []).concat(items);
else this.contacts = items;
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
this.contactsHasMore = this.contacts.length < this.resultCount;
} else if (this.tab === 'tenders') {
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
var items = tenders.items || [];
if (this.q && this.q.trim()) {
var ql = this.q.trim().toLowerCase();
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
}
this.tenders = items;
this.resultCount = items.length;
} else if (this.tab === 'gov') {
var gov = await fetch(base + '/gov-sources' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
if (this.q && this.q.trim()) {
var ql2 = this.q.trim().toLowerCase();
gov = gov.filter((g) => (g.name || '').toLowerCase().includes(ql2) || (g.category || '').toLowerCase().includes(ql2));
}
this.govSources = gov;
this.resultCount = gov.length;
} else if (this.tab === 'pipeline') {
var pipeQs = this.filterQs();
var pipe = await fetch(base + '/crm/pipeline?limit=200' + pipeQs).then((x) => x.json());
this.crmPipeline = pipe.items || [];
this.resultCount = this.crmPipeline.length;
}
},
async loadMap() {
this.clearListFocus();
this.mapLoading = true;
this.mapError = '';
var url = '/api/export-intel/map/bundle';
var qs = [];
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
if (this.region) qs.push('region=' + encodeURIComponent(this.region));
var mt = this.mapTypesParam();
if (mt.entity_type) qs.push('entity_type=' + encodeURIComponent(mt.entity_type));
else if (mt.entity_types) qs.push('entity_types=' + encodeURIComponent(mt.entity_types));
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
if (this.mapHalalMode) qs.push('halal_min=' + encodeURIComponent(this.mapHalalMin));
if (this.filterFavoritesOnly) qs.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
if (qs.length) url += '?' + qs.join('&');
var bundle;
try {
bundle = await fetch(url).then((r) => r.json());
} catch (e) {
this.mapLoading = false;
this.mapError = 'Kaart laden mislukt. Probeer opnieuw.';
return;
}
this.halalTopMarkets = (bundle.meta && bundle.meta.top_markets) || [];
this.halalTopEntities = (bundle.meta && bundle.meta.top_halal_entities) || [];
this.entities = (bundle.meta && bundle.meta.sidebar_entities) || this.halalTopEntities || [];
this.resultCount = (bundle.meta && bundle.meta.entity_count) || this.entities.length;
this.mapTruncated = !!(bundle.meta && bundle.meta.truncated);
if (!window.L || !window.ExportIntelMap) {
this.mapError = 'Kaart kon niet laden. Vernieuw de pagina (Ctrl+F5).';
this.mapLoading = false;
return;
}
this.mapError = '';
if (window.ExportIntelMap) {
if (!document.getElementById('ei-map')) { this.mapLoading = false; return; }
var self = this;
window.ExportIntelMap.init('ei-map');
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
var autoFit = !!(this.country || this.region);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit });
if (this.country) {
var t = this.territories.find((x) => x.country_iso2 === this.country);
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
}
}
this.mapLoading = false;
},
setTab(id, el) {
this.tab = id;
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
var u = new URL(location.href);
u.searchParams.set('tab', id);
history.replaceState(null, '', u.pathname + u.search);
this.loadTabData();
if (id === 'map') this.$nextTick(() => this.loadMap());
this.$nextTick(() => this.updateTabIndicator(el));
},
updateTabIndicator(el) {
var nav = this.$refs.vtabsNav;
if (!nav) return;
var btn = el || nav.querySelector('.vtab-btn.active');
if (!btn) return;
var navRect = nav.getBoundingClientRect();
var btnRect = btn.getBoundingClientRect();
this.tabIndicatorTop = btnRect.top - navRect.top + nav.scrollTop;
this.tabIndicatorH = btnRect.height;
},
async onCountryChange() {
await this.refresh();
},
contactPreview(e) {
if (!e) return '—';
if (e.email || e.primary_email) return e.email || e.primary_email;
if (e.phone || e.primary_phone) return e.phone || e.primary_phone;
if (e.website) return e.website.replace(/^https?:\/\//, '').slice(0, 28);
if (e.has_contact) return 'Contact in registry';
return 'Geen contact';
},
async fetchEntityDetail(id) {
var r = await fetch('/api/export-intel/entities/' + id);
if (!r.ok) {
var err = await r.json().catch(function () { return {}; });
throw new Error((err && err.error) || ('HTTP ' + r.status));
}
var data = await r.json();
if (data && data.error) throw new Error(data.error);
return data;
},
async selectListEntity(id) {
if (!id) return;
this.listFocusId = id;
this.listFocusLoading = true;
this.listFocusDetail = null;
try {
var detail = await this.fetchEntityDetail(id);
this.listFocusDetail = detail;
if (window.ExportIntelMap && detail.lat && detail.lon) {
window.ExportIntelMap.flyTo(detail.lat, detail.lon, 14);
window.ExportIntelMap.highlightEntity(detail.id);
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.listFocusId = null;
}
this.listFocusLoading = false;
},
clearListFocus() {
this.listFocusId = null;
this.listFocusDetail = null;
},
async openEntity(id) {
if (!id) return;
this.drawerLoading = true;
this.drawerOpen = true;
try {
if (this.listFocusDetail && this.listFocusDetail.id === id) {
this.selected = this.listFocusDetail;
} else {
this.selected = await this.fetchEntityDetail(id);
this.listFocusId = id;
this.listFocusDetail = this.selected;
}
if (window.ExportIntelMap && this.selected.lat && this.selected.lon) {
window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14);
window.ExportIntelMap.highlightEntity(this.selected.id);
}
if (window.ExportIntelMap && window.ExportIntelMap.closePopup) {
window.ExportIntelMap.closePopup();
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.drawerOpen = false;
this.selected = null;
}
this.drawerLoading = false;
},
closeDrawer() {
this.drawerOpen = false;
this.selected = null;
this.drawerLoading = false;
this.editMode = false;
this.editForm = {};
},
startEdit() {
if (!this.selected) return;
var s = this.selected;
this.editForm = {
name: s.name || '',
entity_type: s.entity_type || '',
country_iso2: s.country_iso2 || '',
city: s.city || '',
address_line: s.address_line || '',
postal_code: s.postal_code || '',
phone: s.phone || '',
email: s.email || '',
website: s.website || '',
volume_band: s.volume_band || '',
pipeline_stage: s.pipeline_stage || 'identified',
confidence: s.confidence != null ? s.confidence : 50,
halal_cert_notes: s.halal_cert_notes || '',
cold_chain: !!s.cold_chain,
};
this.editMode = true;
},
cancelEdit() {
this.editMode = false;
this.editForm = {};
},
async saveEdit() {
if (!this.selected || !this.selected.id) return;
this.busy = true;
this.syncMsg = 'Opslaan…';
try {
var r = await fetch('/api/export-intel/entities/' + this.selected.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.editForm),
});
if (!r.ok) {
var err = await r.json().catch(function () { return {}; });
throw new Error(err.detail || err.error || 'Opslaan mislukt');
}
this.selected = await r.json();
this.editMode = false;
this.syncMsg = 'Opgeslagen';
await this.refresh();
} catch (e) {
this.syncMsg = 'Opslaan mislukt: ' + e.message;
}
this.busy = false;
},
async syncKind(kind) {
if (kind === 'world') {
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return;
}
if (kind === 'region' && !this.region) {
this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
return;
}
this.busy = true;
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
try {
var body = {
country_iso2: this.country || null,
region_code: kind === 'region' ? this.region : (this.region || null),
max_priority: 2,
};
if (kind === 'world') body = { max_priority: 2 };
var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
if (r.error) this.syncMsg = 'Fout: ' + r.error;
else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst';
else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid';
else this.syncMsg = 'Sync voltooid';
await this.refresh();
} catch (e) {
this.syncMsg = 'Sync mislukt: ' + e.message;
}
this.busy = false;
},
tabCount(id) {
if (id === 'distributors') return this.stats.distributors || 0;
if (id === 'caterers') return this.stats.caterers || 0;
if (id === 'contacts') return this.stats.contacts || 0;
if (id === 'tenders') return this.stats.tenders_open || 0;
if (id === 'pipeline') return this.stats.crm_linked || 0;
if (id === 'map') return this.stats.entities || 0;
return 0;
},
tabSub(id) {
var n = this.tabCount(id);
if (id === 'map') return n + ' op kaart';
if (id === 'gov') return 'Bronnen per land';
if (id === 'pipeline') return 'CRM koppeling';
if (n > 0) return n + ' records';
return '';
},
contactsExportUrl() {
var u = '/api/export-intel/contacts/export.csv?';
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
if (this.filterHasEmail === 'no') parts.push('has_email=false');
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
return u + parts.join('&');
},
async loadMoreContacts() {
if (!this.contactsHasMore || this.contactsLoadingMore) return;
this.contactsLoadingMore = true;
try {
await this.loadTabData({ append: true });
} finally {
this.contactsLoadingMore = false;
}
},
typeLabel(t) {
return (t || '').replace(/_/g, ' ');
},
territoryName(iso2) {
if (!iso2) return '';
var t = this.territories.find(function (x) { return x.country_iso2 === iso2; });
return t ? t.name_nl : iso2;
},
isSelected(id) {
return this.selectedIds.indexOf(id) >= 0;
},
toggleSelect(id, ev) {
if (ev) ev.stopPropagation();
var i = this.selectedIds.indexOf(id);
if (i >= 0) this.selectedIds.splice(i, 1);
else this.selectedIds.push(id);
},
toggleSelectAllVisible() {
if (this.tab === 'contacts') {
var entityIds = [];
(this.contacts || []).forEach(function (c) {
if (c.entity_id && entityIds.indexOf(c.entity_id) < 0) entityIds.push(c.entity_id);
});
if (!entityIds.length) return;
var allSelected = entityIds.every((id) => this.isSelected(id));
if (allSelected) {
entityIds.forEach((id) => {
var i = this.selectedIds.indexOf(id);
if (i >= 0) this.selectedIds.splice(i, 1);
});
} else {
entityIds.forEach((id) => {
if (!this.isSelected(id)) this.selectedIds.push(id);
});
}
return;
}
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id));
if (allSelected) {
list.forEach((e) => {
var i = this.selectedIds.indexOf(e.id);
if (i >= 0) this.selectedIds.splice(i, 1);
});
} else {
list.forEach((e) => {
if (!this.isSelected(e.id)) this.selectedIds.push(e.id);
});
}
},
clearSelection() {
this.selectedIds = [];
},
async setFavorites(ids, favorite) {
if (!ids.length) return;
this.busy = true;
try {
await fetch('/api/export-intel/entities/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_ids: ids, favorite: favorite }),
});
this.syncMsg = favorite
? ids.length + ' favoriet' + (ids.length > 1 ? 'en' : '') + ' opgeslagen'
: 'Favoriet verwijderd';
await this.refresh();
} catch (e) {
this.syncMsg = 'Favoriet mislukt: ' + e.message;
}
this.busy = false;
},
async toggleFavorite(id, current, ev) {
if (ev) ev.stopPropagation();
await this.setFavorites([id], !current);
},
async favoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), true);
},
async unfavoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), false);
},
openCrmModal() {
if (!this.selectedIds.length) {
this.syncMsg = 'Selecteer eerst één of meer rijen (checkbox)';
return;
}
this.crmModalOpen = true;
},
async pushToCrm(ids) {
var entityIds = (ids && ids.length) ? ids : this.selectedIds.slice();
if (!entityIds.length) return;
this.crmPushBusy = true;
this.syncMsg = 'CRM import gestart…';
try {
var r = await fetch('/api/export-intel/crm/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_ids: entityIds,
create_deals: this.crmCreateDeals,
}),
}).then((x) => x.json());
if (r.error) throw new Error(r.error);
var created = r.created || 0;
var linked = r.linked_existing || 0;
this.syncMsg = 'CRM: ' + created + ' nieuw, ' + linked + ' bestaand gekoppeld';
this.crmModalOpen = false;
this.selectedIds = [];
await this.refresh();
if (this.listFocusDetail && entityIds.indexOf(this.listFocusDetail.id) >= 0) {
this.listFocusDetail = await this.fetchEntityDetail(this.listFocusDetail.id);
}
if (this.tab === 'pipeline') await this.loadTabData();
} catch (e) {
this.syncMsg = 'CRM import mislukt: ' + e.message;
}
this.crmPushBusy = false;
},
async pushEntityToCrm(id) {
this.crmCreateDeals = true;
await this.pushToCrm([id]);
},
onFilterCollectionChange() {
if (this.tab === 'contacts') {
this.loadTabData();
return;
}
this.applyMapFilters();
},
};
}
+373
View File
@@ -0,0 +1,373 @@
/**
* Herman Assistant — browser widget + gedeelde chat/voice logica
*/
window.HermanAssistant = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
butcher: 'Slager', doner: 'Döner',
};
return m[t] || t || '—';
}
function newSessionId(prefix) {
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
}
function baseState(opts) {
opts = opts || {};
return {
channel: opts.channel || 'browser',
sessionPrefix: opts.sessionPrefix || 'br',
open: false,
hidden: false,
status: 'idle',
statusDetail: '',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: true,
handsFreeMode: false,
useBrowserPreview: !!SpeechRecognition,
turns: [],
manualText: '',
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
sessionId: newSessionId(opts.sessionPrefix || 'br'),
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
holdIgnoreClick: false,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
initWidget: function () { /* overridden in browserWidget */ },
toggleOpen: function () {
this.open = !this.open;
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
},
scrollLog: function () {
var self = this;
this.$nextTick(function () {
var log = self.$refs.chatLog;
if (log) log.scrollTop = log.scrollHeight;
});
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.scrollLog();
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
},
closeResultsModal: function () { this.resultsOpen = false; },
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
},
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
this.manualText = 'ja';
this.sendText(this.pendingAction.id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendText();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '', final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
this.livePreview = '';
this.status = 'listening';
this.statusDetail = 'Luisteren…';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if (blob.size > 0) self.processVoice(blob);
else { self.status = 'idle'; self.statusDetail = ''; }
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast('Microfoon: ' + e.message, 'error');
}
},
stopListen: function () {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen: function () {
if (this.recording) this.stopListen();
else this.startListen();
},
micClass: function () {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
return '';
},
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
onMicUp: function () {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick: function () {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
async processVoice(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
var text = j.text || '';
this.livePreview = text;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'voice' });
this.handleHermanResponse(j.herman || {});
this.status = 'idle';
this.statusDetail = '';
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
async sendText(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
try {
var body = { message: text, channel: this.channel, session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.handleHermanResponse(r);
this.status = 'idle';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
}
this.busy = false;
},
formatDelegated: function (meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
}
return {
browserWidget: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
s.initWidget = function () {
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
try {
var raw = localStorage.getItem('herman_assistant_open');
if (raw === '1') this.open = true;
} catch (e) { /* empty */ }
};
return s;
},
chatPage: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
s.imagePrompt = '';
s.generating = false;
s.lastImage = '';
s.initChat = function () {
this.open = true;
};
s.generateImage = async function () {
if (!this.imagePrompt.trim()) return;
this.generating = true;
try {
var r = await Cockpit.api('/api/ai/generate-image', {
method: 'POST',
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 }),
});
if (r.proxy_url) {
this.lastImage = r.proxy_url;
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
}
Cockpit.toast('Afbeelding gegenereerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.generating = false;
};
return s;
},
entityTypeLabel: entityTypeLabel,
};
})();
function hermanBrowserWidget() {
return HermanAssistant.browserWidget();
}
+1
View File
File diff suppressed because one or more lines are too long
+9 -2
View File
@@ -17,7 +17,14 @@ window.I18n = (function () {
function applyDom() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n');
if (k) el.textContent = t(k);
if (!k) return;
var text = t(k);
/* Sidebar nav: never replace whole link (destroys icons) */
if (el.classList && el.classList.contains('nav-pill')) {
var lbl = el.querySelector('.sidebar-nav-label');
if (lbl) { lbl.textContent = text; return; }
}
el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
var k = el.getAttribute('data-i18n-placeholder');
@@ -31,7 +38,7 @@ window.I18n = (function () {
}
async function loadBundle(loc) {
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
var r = await fetch('/static/i18n/' + loc + '.json?v=3');
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
strings = await r.json();
}
+23 -1
View File
@@ -9,19 +9,41 @@ window.CockpitLive = (function () {
return function () { clearInterval(id); };
}
function eventFingerprint(data) {
var ev = (data && data.events) || [];
if (!ev.length) return '';
return ev.slice(0, 5).map(function (e) {
return String(e.created_at || '') + '#' + String(e.agent_name || '') + '#' + String(e.title || e.event_type || '');
}).join('|');
}
function connectFeed(onMessage) {
if (typeof WebSocket === 'undefined') return function () {};
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var lastFp = '';
var booted = false;
function connect() {
try {
ws = new WebSocket(proto + '//' + location.host + '/ws/feed');
ws.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (onMessage) onMessage(data);
var fp = eventFingerprint(data);
if (!booted) {
booted = true;
lastFp = fp;
if (onMessage) onMessage(data, { reason: 'connect' });
return;
}
if (fp && fp !== lastFp) {
lastFp = fp;
if (onMessage) onMessage(data, { reason: 'events' });
}
} catch (e) {}
};
ws.onclose = function () {
booted = false;
lastFp = '';
reconnectTimer = setTimeout(connect, 5000);
};
} catch (e) {
+143
View File
@@ -0,0 +1,143 @@
(function () {
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function pageMatches(page, filters) {
const q = (filters.search || '').trim().toLowerCase();
const terms = filters.terms || [];
const siteId = filters.siteId ? String(filters.siteId) : '';
const minWords = parseInt(filters.minWords, 10) || 0;
const minHype = parseInt(filters.minHype, 10) || 0;
if (siteId && String(page.site_id) !== siteId) return false;
if ((page.word_count || 0) < minWords) return false;
if ((page.hype_score || 0) < minHype) return false;
if (filters.changedOnly && !page.recently_changed) return false;
const hay = [
page.site_name,
page.title,
page.url,
page.excerpt,
page.content_read,
page.meta_description,
(page.headings || []).join(' '),
(page.signals || []).join(' '),
(page.links_sample || []).map(function (l) {
return (l.label || '') + ' ' + (l.href || '');
}).join(' '),
]
.join(' ')
.toLowerCase();
if (q && hay.indexOf(q) < 0) return false;
for (let i = 0; i < terms.length; i++) {
if (hay.indexOf(String(terms[i]).toLowerCase()) < 0) return false;
}
return true;
}
function sortPages(pages, col, dir) {
const mul = dir === 'asc' ? 1 : -1;
const copy = pages.slice();
copy.sort(function (a, b) {
let va;
let vb;
switch (col) {
case 'site_name':
va = (a.site_name || '').toLowerCase();
vb = (b.site_name || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'title':
va = (a.title || a.url || '').toLowerCase();
vb = (b.title || b.url || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'word_count':
return ((a.word_count || 0) - (b.word_count || 0)) * mul;
case 'links_count':
return ((a.links_count || 0) - (b.links_count || 0)) * mul;
case 'crawled_at':
va = a.crawled_at || '';
vb = b.crawled_at || '';
return va < vb ? -mul : va > vb ? mul : 0;
case 'hype_score':
default:
return ((a.hype_score || 0) - (b.hype_score || 0)) * mul;
}
});
return copy;
}
function filterPages(pages, filters) {
if (!pages || !pages.length) return [];
return pages.filter(function (p) {
return pageMatches(p, filters);
});
}
function highlightHtml(text, queries) {
const raw = text || '';
if (!raw) return '<span class="muted">Geen tekst</span>';
let html = esc(raw);
const list = [];
if (queries && queries.length) {
queries.forEach(function (q) {
if (q && String(q).trim()) list.push(String(q).trim());
});
}
list.sort(function (a, b) {
return b.length - a.length;
});
list.forEach(function (term) {
if (term.length < 2) return;
const re = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
html = html.replace(re, '<mark>$1</mark>');
});
return html;
}
function exportCsv(pages) {
const rows = [['site', 'title', 'url', 'words', 'hype', 'changed', 'crawled_at', 'excerpt']];
pages.forEach(function (p) {
rows.push([
p.site_name || '',
(p.title || '').replace(/"/g, '""'),
p.url || '',
String(p.word_count || 0),
String(p.hype_score || 0),
p.recently_changed ? 'yes' : 'no',
p.crawled_at || '',
(p.excerpt || '').replace(/"/g, '""').slice(0, 500),
]);
});
return rows
.map(function (r) {
return r.map(function (c) {
return '"' + c + '"';
}).join(',');
})
.join('\n');
}
function downloadCsv(pages, filename) {
const csv = exportCsv(pages);
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || 'parse-analyse.csv';
a.click();
URL.revokeObjectURL(a.href);
}
window.ParseIntel = {
filterPages: filterPages,
sortPages: sortPages,
highlightHtml: highlightHtml,
exportCsv: exportCsv,
downloadCsv: downloadCsv,
};
})();
+429
View File
@@ -0,0 +1,429 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
_pollStop: null,
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();
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() + '…';
},
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;
},
};
}
+464
View File
@@ -0,0 +1,464 @@
/**
* Voice Live — push-to-talk, hands-free, export results popup, Herman bevestiging
*/
window.VoiceLive = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur',
wholesaler: 'Groothandel',
importer: 'Importeur',
logistics: 'Logistiek',
restaurant: 'Restaurant',
caterer: 'Cateraar',
butcher: 'Slager',
doner: 'Döner',
};
return m[t] || t || '—';
}
return {
page: function () {
return {
status: 'idle',
statusDetail: 'Houd de microfoon ingedrukt en spreek',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
conversationMode: true,
handsFreeMode: false,
speakReply: true,
useBrowserPreview: !!SpeechRecognition,
pipeline: [],
feed: [],
turns: [],
manualText: '',
holdIgnoreClick: false,
sessionId: 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
agentEs: null,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
init: function () {
var self = this;
this.connectAgentFeed();
window.addEventListener('keydown', function (e) {
if (e.code === 'Space' && !e.target.matches('input, textarea') && !self.recording && !self.busy) {
e.preventDefault();
self.startListen();
}
});
window.addEventListener('keyup', function (e) {
if (e.code === 'Space' && self.recording && !e.target.matches('input, textarea')) {
e.preventDefault();
self.stopListen();
}
});
},
destroy: function () {
if (this._resumeTimer) clearTimeout(this._resumeTimer);
if (this._silenceTimer) clearTimeout(this._silenceTimer);
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
if (this.agentEs) {
this.agentEs.close();
this.agentEs = null;
}
this._stopRecognition();
this._stopStream();
},
connectAgentFeed: function () {
var self = this;
if (typeof EventSource === 'undefined') return;
try {
this.agentEs = new EventSource('/api/agents/live/stream');
this.agentEs.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (!data || data.type === 'connected') return;
var ch = (data.channel || '').toLowerCase();
var agent = (data.agent || data.agent_name || '').toLowerCase();
if (ch !== 'voice' && agent !== 'herman' && agent !== 'voice' && agent !== 'sourcing') return;
self.feed.unshift({
at: nowTime(),
agent: data.agent || data.agent_name || 'agent',
message: data.message || data.title || '',
});
if (self.feed.length > 40) self.feed.pop();
} catch (e) { /* empty */ }
};
} catch (e) { /* empty */ }
},
pushPipeline: function (entries) {
if (!entries || !entries.length) return;
this.pipeline = this.pipeline.concat(entries);
if (this.pipeline.length > 60) this.pipeline = this.pipeline.slice(-60);
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.$nextTick(function () {
var log = document.getElementById('voice-chat-log');
if (log) log.scrollTop = log.scrollHeight;
});
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
needs_confirmation: h.needs_confirmation,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording && self.status !== 'error') {
self.statusDetail = 'Hands-free — luisteren…';
self.startListen();
}
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel resultaten';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Popup geopend',
detail: this.resultsTotal + ' resultaten',
status: 'done',
at: new Date().toISOString(),
}]);
},
closeResultsModal: function () {
this.resultsOpen = false;
},
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Agy build gestart',
detail: this.webbuilderProject,
status: 'running',
at: new Date().toISOString(),
}]);
},
closeWebbuilderModal: function () {
this.webbuilderOpen = false;
},
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
var id = this.pendingAction.id;
this.manualText = 'ja';
this.sendManual(id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendManual();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '';
var final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.onerror = function () { /* optional */ };
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this.livePreview = '';
this.liveInterim = false;
if (!this.handsFreeMode) this.pipeline = [];
this.status = 'listening';
this.statusDetail = this.handsFreeMode
? 'Hands-free — spreek je vraag'
: 'Spreek nu — laat los om te versturen';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if ((self.conversationMode || self.handsFreeMode) && blob.size > 0) self.processTurn(blob);
else if (blob.size > 0) {
self.status = 'idle';
self.statusDetail = 'Opname klaar';
}
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
var self = this;
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = 'Microfoon: ' + e.message;
Cockpit.toast(this.statusDetail, 'error');
}
},
stopListen() {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen() {
if (this.recording) this.stopListen();
else this.startListen();
},
toggleHandsFree() {
if (this.handsFreeMode && !this.recording && !this.busy) {
Cockpit.toast('Hands-free aan — ik luister na elk antwoord opnieuw', 'success');
this.startListen();
} else if (!this.handsFreeMode && this.recording) {
this.stopListen();
}
},
onHandsFreeChange() {
if (this.handsFreeMode) this.toggleHandsFree();
else if (this.recording) this.stopListen();
},
async processTurn(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
this.pushPipeline([{
phase: 'capture',
label: 'Audio verstuurd',
detail: Math.round(blob.size / 1024) + ' KB',
status: 'done',
at: new Date().toISOString(),
}]);
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice turn mislukt');
this.pushPipeline(j.pipeline || []);
var text = j.text || '';
this.livePreview = text;
this.liveInterim = false;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'whisper' });
var h = j.herman || {};
this.handleHermanResponse(h);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar — spreek opnieuw';
if (!h.needs_confirmation && h.reply) Cockpit.toast('Herman antwoordde', 'success');
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
this.pushPipeline([{ phase: 'error', label: 'Fout', detail: e.message, status: 'error', at: new Date().toISOString() }]);
Cockpit.toast(e.message, 'error');
this.maybeResumeListening(1500);
}
this.busy = false;
},
async sendManual(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Herman denkt na…';
var confirmId = confirmActionId || (this.pendingAction && this.pendingAction.id) || null;
if (confirmId && (text.toLowerCase() === 'ja' || confirmActionId)) {
/* confirm via button passes id explicitly */
}
this.pushPipeline([{ phase: 'herman', label: 'Tekst naar Herman', detail: text.slice(0, 120), status: 'running', at: new Date().toISOString() }]);
try {
var body = { message: text, channel: 'voice', session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
if (this.pipeline.length) this.pipeline[this.pipeline.length - 1].status = 'done';
this.addTurn('user', text, { source: 'typed' });
this.handleHermanResponse(r);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
this.statusDetail = e.message;
}
this.busy = false;
},
clearSession() {
this.turns = [];
this.pipeline = [];
this.feed = [];
this.livePreview = '';
this.manualText = '';
this.pendingAction = null;
this.resultsOpen = false;
this.sessionId = 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
this.status = 'idle';
this.statusDetail = 'Sessie gewist';
},
micClass() {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
if (this.handsFreeMode) return 'is-handsfree';
return '';
},
onMicDown() { this.holdIgnoreClick = true; this.startListen(); },
onMicUp() {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick() {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
formatDelegated(meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
},
};
})();

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