Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d74c146aa | |||
| ff22498dcd | |||
| 8d2766efb6 | |||
| 6856d09cd1 | |||
| 7289b770e5 | |||
| 8712f503b1 | |||
| 171878ee1d | |||
| 4dd1b8265f | |||
| 544d9e611d | |||
| 26fe76afdd |
@@ -13,3 +13,6 @@ cockpit/static/uploads/
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
tmp/
|
||||
cockpit/tmp-upload/
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+130
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+1160
-31
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 & M&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 & 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 %}
|
||||
@@ -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)
|
||||
@@ -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());
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -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 & 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 & 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 & 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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () {});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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,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' },
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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: '© OSM © 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 };
|
||||
})();
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user