Compare commits
19 Commits
3bf15c4850
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f925042a93 | |||
| c5ea8cbb63 | |||
| 910ba465d7 | |||
| 1dd607b060 | |||
| 22a17ed048 | |||
| 5e43b6b246 | |||
| 62239f0a4e | |||
| 5dd69a1138 | |||
| f21d58483b | |||
| 0d74c146aa | |||
| ff22498dcd | |||
| 8d2766efb6 | |||
| 6856d09cd1 | |||
| 7289b770e5 | |||
| 8712f503b1 | |||
| 171878ee1d | |||
| 4dd1b8265f | |||
| 544d9e611d | |||
| 26fe76afdd |
@@ -21,3 +21,13 @@ PROXMOX_HOST=10.4.7.14
|
||||
PROXMOX_USER=root@pam
|
||||
PROXMOX_TOKEN_NAME=
|
||||
PROXMOX_TOKEN_VALUE=
|
||||
|
||||
# OpenRouter (optioneel — ook via Instellingen → AI / LLM in cockpit)
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# orchestrator | router | auto (auto = orchestrator met fallback naar standaard LLM)
|
||||
HERMAN_LLM_BACKEND=auto
|
||||
|
||||
# Google Places (Export Intel — telefoon/website verrijking)
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
|
||||
@@ -13,3 +13,12 @@ cockpit/static/uploads/
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
tmp/
|
||||
cockpit/tmp-upload/
|
||||
|
||||
# Local TLS private keys (self-signed cockpit HTTPS)
|
||||
cockpit/caddy/certs/*.key
|
||||
cockpit/caddy/certs/key.pem
|
||||
cockpit/caddy/certs/*.srl
|
||||
cockpit/caddy/certs/*.csr
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Cockpit user accounts: password hashing, CRUD, permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
# Nav / feature keys shown in Settings → Gebruikers
|
||||
PAGE_CATALOG: list[dict[str, str]] = [
|
||||
{"key": "dashboard", "label": "Overzicht (CEO)"},
|
||||
{"key": "revenue", "label": "Revenue Cockpit"},
|
||||
{"key": "retail", "label": "Retail NL"},
|
||||
{"key": "marketing", "label": "Marketing"},
|
||||
{"key": "packaging", "label": "Packaging"},
|
||||
{"key": "beurs", "label": "Beurs Live"},
|
||||
{"key": "documents", "label": "Documenten"},
|
||||
{"key": "projects", "label": "Projecten"},
|
||||
{"key": "hermes", "label": "Telegram / Hermes"},
|
||||
{"key": "analytics", "label": "Analytics"},
|
||||
{"key": "export", "label": "Wereldexport"},
|
||||
{"key": "clients", "label": "Klanten"},
|
||||
{"key": "deals", "label": "Deals"},
|
||||
{"key": "products", "label": "Producten"},
|
||||
{"key": "suppliers", "label": "Leveranciers"},
|
||||
{"key": "reports", "label": "Rapporten"},
|
||||
{"key": "ops", "label": "IT Ops"},
|
||||
{"key": "agents", "label": "Agents"},
|
||||
{"key": "browser", "label": "Browser"},
|
||||
{"key": "studio", "label": "Studio"},
|
||||
{"key": "voice", "label": "Voice"},
|
||||
{"key": "settings", "label": "Instellingen"},
|
||||
]
|
||||
|
||||
# path prefix → permission key (order: longest prefix first)
|
||||
PATH_RULES: list[tuple[str, str]] = [
|
||||
("/revenue-cockpit", "revenue"),
|
||||
("/export-intel", "export"),
|
||||
("/packaging", "packaging"),
|
||||
("/marketing", "marketing"),
|
||||
("/documents", "documents"),
|
||||
("/analytics", "analytics"),
|
||||
("/suppliers", "suppliers"),
|
||||
("/products", "products"),
|
||||
("/projects", "projects"),
|
||||
("/settings", "settings"),
|
||||
("/clients", "clients"),
|
||||
("/browser", "browser"),
|
||||
("/monitor", "browser"),
|
||||
("/hermes", "hermes"),
|
||||
("/retail", "retail"),
|
||||
("/reports", "reports"),
|
||||
("/studio", "studio"),
|
||||
("/agents", "agents"),
|
||||
("/beurs", "beurs"),
|
||||
("/deals", "deals"),
|
||||
("/voice", "voice"),
|
||||
("/ops", "ops"),
|
||||
("/", "dashboard"),
|
||||
]
|
||||
|
||||
ADMIN_SEED_USERNAME = "aissa"
|
||||
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
rounds = 200_000
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
|
||||
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
algo, rounds_s, salt, digest = password_hash.split("$", 3)
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
rounds = int(rounds_s)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
|
||||
return secrets.compare_digest(dk.hex(), digest)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_perms(raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {"pages": [], "manage_users": False}
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
return {"pages": [], "manage_users": False}
|
||||
if not isinstance(raw, dict):
|
||||
return {"pages": [], "manage_users": False}
|
||||
pages = raw.get("pages") or []
|
||||
if not isinstance(pages, list):
|
||||
pages = []
|
||||
return {
|
||||
"pages": [str(p) for p in pages],
|
||||
"manage_users": bool(raw.get("manage_users")),
|
||||
}
|
||||
|
||||
|
||||
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
perms = _normalize_perms(row.get("permissions"))
|
||||
role = row.get("role") or "user"
|
||||
if role == "admin":
|
||||
perms = {
|
||||
"pages": [p["key"] for p in PAGE_CATALOG],
|
||||
"manage_users": True,
|
||||
}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"display_name": row.get("display_name") or row["username"],
|
||||
"role": role,
|
||||
"is_active": bool(row.get("is_active", True)),
|
||||
"avatar_url": row.get("avatar_url") or None,
|
||||
"permissions": perms,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
|
||||
}
|
||||
|
||||
|
||||
def ensure_admin_seed() -> None:
|
||||
"""Create default admin aissa if table empty / user missing."""
|
||||
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
|
||||
if row:
|
||||
return
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
|
||||
VALUES (%s, %s, %s, 'admin', %s::jsonb)
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
""",
|
||||
(
|
||||
ADMIN_SEED_USERNAME,
|
||||
hash_password(ADMIN_SEED_PASSWORD),
|
||||
"Aissa",
|
||||
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one(
|
||||
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
|
||||
(username.strip(),),
|
||||
)
|
||||
if not row or not row.get("is_active"):
|
||||
return None
|
||||
if not verify_password(password, row["password_hash"]):
|
||||
return None
|
||||
execute(
|
||||
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
|
||||
(row["id"],),
|
||||
)
|
||||
return public_user(row)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
return public_user(row)
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
|
||||
return [public_user(r) for r in rows if r]
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str | None,
|
||||
role: str,
|
||||
permissions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
|
||||
if role not in ("admin", "user"):
|
||||
role = "user"
|
||||
perms = _normalize_perms(permissions)
|
||||
if role == "admin":
|
||||
perms["manage_users"] = True
|
||||
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING *
|
||||
""",
|
||||
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
|
||||
)
|
||||
return public_user(row) # type: ignore
|
||||
|
||||
|
||||
def update_user(
|
||||
user_id: int,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
new_name = display_name if display_name is not None else row.get("display_name")
|
||||
new_role = role if role in ("admin", "user") else row["role"]
|
||||
new_active = row["is_active"] if is_active is None else bool(is_active)
|
||||
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
|
||||
if new_role == "admin":
|
||||
new_perms["manage_users"] = True
|
||||
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
|
||||
pw_hash = row["password_hash"]
|
||||
if password:
|
||||
pw_hash = hash_password(password)
|
||||
updated = fetch_one(
|
||||
"""
|
||||
UPDATE cockpit_users
|
||||
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
|
||||
password_hash = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
|
||||
)
|
||||
return public_user(updated) # type: ignore
|
||||
|
||||
|
||||
def delete_user(user_id: int, *, actor_id: int) -> None:
|
||||
if user_id == actor_id:
|
||||
raise ValueError("Je kunt jezelf niet verwijderen")
|
||||
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
if row["role"] == "admin":
|
||||
admins = fetch_one(
|
||||
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
|
||||
)
|
||||
if admins and admins["n"] <= 1:
|
||||
raise ValueError("Laatste admin kan niet verwijderd worden")
|
||||
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pages = set((user.get("permissions") or {}).get("pages") or [])
|
||||
# normalize
|
||||
p = path.split("?")[0] or "/"
|
||||
if p != "/" and p.endswith("/"):
|
||||
p = p.rstrip("/")
|
||||
for prefix, key in PATH_RULES:
|
||||
if prefix == "/":
|
||||
if p == "/" or p == "":
|
||||
return "dashboard" in pages
|
||||
continue
|
||||
if p == prefix or p.startswith(prefix + "/"):
|
||||
return key in pages
|
||||
return "dashboard" in pages
|
||||
|
||||
|
||||
def can_manage_users(user: dict[str, Any] | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
return bool((user.get("permissions") or {}).get("manage_users"))
|
||||
|
||||
|
||||
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
UPDATE cockpit_users
|
||||
SET avatar_url = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(avatar_url, user_id),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
return public_user(row) # type: ignore
|
||||
@@ -10,9 +10,31 @@ class Settings:
|
||||
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
|
||||
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
|
||||
# OpenRouter (optioneel — key ook via Instellingen → AI / LLM)
|
||||
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-v4-flash")
|
||||
OPENROUTER_BASE_URL: str = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
||||
# orchestrator | router | auto (auto = orchestrator, fallback naar standaard LLM provider)
|
||||
HERMAN_LLM_BACKEND: str = os.getenv("HERMAN_LLM_BACKEND", "auto")
|
||||
# Hermes Agent (OpenAI-compatible) — Herman briefing backend
|
||||
HERMES_API_BASE: str = os.getenv("HERMES_API_BASE", "http://10.4.7.10:8642/v1")
|
||||
HERMES_API_KEY: str = os.getenv("HERMES_API_KEY", "")
|
||||
HERMES_API_MODEL: str = os.getenv("HERMES_API_MODEL", "hermes-agent")
|
||||
WARROOM_MEET_BASE: str = os.getenv("WARROOM_MEET_BASE", "https://meet.jit.si")
|
||||
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
|
||||
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
||||
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
||||
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
|
||||
# Bind cockpit chat aan Hermes/Telegram memory (chat_id als OpenAI "user")
|
||||
HERMES_CEO_CHAT_ID: str = os.getenv("HERMES_CEO_CHAT_ID", "8859782446")
|
||||
HERMES_CTO_CHAT_ID: str = os.getenv("HERMES_CTO_CHAT_ID", "789036463")
|
||||
HERMES_MEMORY_LIMIT: int = int(os.getenv("HERMES_MEMORY_LIMIT", "24"))
|
||||
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
|
||||
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
|
||||
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
|
||||
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
SESSION_SECRET: str = os.getenv("SESSION_SECRET", "foodlinkk-cockpit-session-change-me-2026")
|
||||
SESSION_MAX_AGE: int = int(os.getenv("SESSION_MAX_AGE", str(60 * 60 * 24 * 14))) # 14 days
|
||||
|
||||
@property
|
||||
def database_dsn(self) -> str:
|
||||
|
||||
+158
-1
@@ -3,8 +3,11 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.db import close_pool, fetch_all, init_pool
|
||||
from app.routes import (
|
||||
@@ -31,9 +34,15 @@ from app.routes import (
|
||||
packaging,
|
||||
ops,
|
||||
ops_api,
|
||||
revenue_cockpit,
|
||||
export_intel,
|
||||
)
|
||||
from app.routes.revenue_cockpit import api as revenue_cockpit_api
|
||||
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
|
||||
from app.routes.settings_api import settings_router
|
||||
from app.routes import auth_routes
|
||||
from app import auth_users
|
||||
from app.config import settings as app_settings
|
||||
from app.routes.agents_api import router as agents_api_router
|
||||
from app.routes.marketing_api import router as marketing_api_router
|
||||
from app.routes.projects_api import router as projects_api_router
|
||||
@@ -41,8 +50,146 @@ from app.routes.projects_api import router as projects_api_router
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""Serve static assets without browser/SW long-lived caching."""
|
||||
|
||||
async def get_response(self, path: str, scope) -> Response:
|
||||
response = await super().get_response(path, scope)
|
||||
if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
PUBLIC_PREFIXES = (
|
||||
"/login",
|
||||
"/logout",
|
||||
"/static/",
|
||||
"/sw.js",
|
||||
"/manifest.webmanifest",
|
||||
"/static/manifest.json",
|
||||
"/api/auth/login",
|
||||
)
|
||||
PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"}
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
if path in PUBLIC_EXACT:
|
||||
return True
|
||||
for pref in PUBLIC_PREFIXES:
|
||||
if path == pref or path.startswith(pref):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AuthGateMiddleware:
|
||||
"""Require login; must run *inside* SessionMiddleware."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
request = Request(scope, receive=receive)
|
||||
path = request.url.path
|
||||
user = None
|
||||
try:
|
||||
uid = request.session.get("user_id")
|
||||
except Exception:
|
||||
uid = None
|
||||
if uid:
|
||||
try:
|
||||
user = auth_users.get_user_by_id(int(uid))
|
||||
except Exception:
|
||||
user = None
|
||||
if not user or not user.get("is_active"):
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
user = None
|
||||
request.state.user = user
|
||||
|
||||
async def call_next():
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
if _is_public(path):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if not user:
|
||||
if path.startswith("/api/") or path.startswith("/ws"):
|
||||
response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401)
|
||||
else:
|
||||
nxt = path
|
||||
if request.url.query:
|
||||
nxt = f"{path}?{request.url.query}"
|
||||
from urllib.parse import quote
|
||||
response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
if (
|
||||
not path.startswith("/api/")
|
||||
and not path.startswith("/ws")
|
||||
and not path.startswith("/static")
|
||||
and not auth_users.path_allowed(user, path)
|
||||
):
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
# Order: first added = innermost. Session must be outermost so request.session works.
|
||||
app.add_middleware(AuthGateMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=app_settings.SESSION_SECRET,
|
||||
session_cookie="flk_session",
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
max_age=app_settings.SESSION_MAX_AGE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False)
|
||||
async def service_worker():
|
||||
"""Root-scoped service worker for installable PWA (Android + iOS Safari)."""
|
||||
sw_path = BASE_DIR / "static" / "sw.js"
|
||||
return FileResponse(
|
||||
sw_path,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False)
|
||||
async def web_manifest():
|
||||
man_path = BASE_DIR / "static" / "manifest.json"
|
||||
return FileResponse(
|
||||
man_path,
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
for r in (
|
||||
dashboard.router,
|
||||
@@ -60,12 +207,16 @@ for r in (
|
||||
reports.router,
|
||||
voice.router,
|
||||
settings.router,
|
||||
auth_routes.router,
|
||||
browser.router,
|
||||
herman.router,
|
||||
studio.router,
|
||||
hermes.router,
|
||||
packaging.router,
|
||||
ops.router,
|
||||
revenue_cockpit.router,
|
||||
export_intel.router,
|
||||
revenue_cockpit_api,
|
||||
api.router,
|
||||
ops_api.router,
|
||||
admin_router,
|
||||
@@ -76,6 +227,8 @@ for r in (
|
||||
agents_api_router,
|
||||
marketing_api_router,
|
||||
projects_api_router,
|
||||
auth_routes.api_router,
|
||||
auth_routes.users_api,
|
||||
):
|
||||
app.include_router(r)
|
||||
|
||||
@@ -83,6 +236,10 @@ for r in (
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_pool()
|
||||
try:
|
||||
auth_users.ensure_admin_seed()
|
||||
except Exception as e:
|
||||
print(f"auth seed warning: {e}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
|
||||
+1290
-33
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.db import execute, fetch_all
|
||||
@@ -21,9 +21,9 @@ AGENT_STATUSES = [
|
||||
|
||||
@router.get("")
|
||||
async def agents_page(request: Request):
|
||||
active_tab = request.query_params.get("tab", "souls")
|
||||
if active_tab not in {"souls", "mesh", "approvals"}:
|
||||
active_tab = "souls"
|
||||
active_tab = request.query_params.get("tab", "warroom")
|
||||
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
|
||||
active_tab = "warroom"
|
||||
events: list = []
|
||||
try:
|
||||
events = fetch_all(
|
||||
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/warroom/popup", response_class=HTMLResponse)
|
||||
async def agents_warroom_popup(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"agents_warroom_popup.html",
|
||||
{"request": request, "page_title": "War Room"},
|
||||
)
|
||||
|
||||
@router.post("/approve/{event_id}")
|
||||
async def approve_event(event_id: int, next_url: str = Form("/")):
|
||||
try:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Agents API — souls & activity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import fetch_all
|
||||
from app.services import agent_approvals, agent_souls
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_approvals, agent_integration, agent_souls
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
from app.services.agent_terminal import execute_terminal_command
|
||||
|
||||
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
|
||||
|
||||
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class HandoffBody(BaseModel):
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
handoff_type: str = "partner"
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
|
||||
class TerminalCommandBody(BaseModel):
|
||||
command: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
|
||||
)
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None and created is not None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
return by_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
@router.get("/collaboration")
|
||||
def api_collaboration() -> dict[str, Any]:
|
||||
items = agent_integration.list_collaboration()
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/handoff")
|
||||
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
|
||||
try:
|
||||
handoff = agent_integration.create_handoff(
|
||||
body.from_agent,
|
||||
body.to_agent,
|
||||
handoff_type=body.handoff_type,
|
||||
payload=body.payload,
|
||||
correlation_id=body.correlation_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"ok": True, "handoff": handoff}
|
||||
|
||||
|
||||
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
meta = ev.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
ev["metadata"] = meta or {}
|
||||
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
|
||||
if str(ev.get("event_type") or "").startswith("terminal_"):
|
||||
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
|
||||
if ev["metadata"].get("target_agent"):
|
||||
line_type = "handoff_out"
|
||||
if ev["metadata"].get("source_agent"):
|
||||
line_type = "handoff_in"
|
||||
if str(ev.get("status") or "") in ("error", "rejected"):
|
||||
line_type = "error"
|
||||
return {
|
||||
"type": line_type,
|
||||
"id": ev.get("id"),
|
||||
"agent": normalize_agent_key(ev.get("agent_name")),
|
||||
"message": ev.get("title") or ev.get("event_type"),
|
||||
"detail": (ev.get("body") or "")[:500],
|
||||
"status": ev.get("status"),
|
||||
"correlation_id": ev["metadata"].get("correlation_id"),
|
||||
"at": ev.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/souls/{agent_key}/terminal/stream")
|
||||
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
hist = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
for row in reversed(hist):
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
try:
|
||||
if correlation_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE metadata->>'correlation_id' = %s
|
||||
AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(correlation_id, last_id),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(key, last_id),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/live/stream")
|
||||
async def api_agents_live_stream():
|
||||
"""Single realtime stream for all agent terminals."""
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
recent = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 minutes'
|
||||
ORDER BY id ASC
|
||||
LIMIT 80
|
||||
"""
|
||||
)
|
||||
for row in recent:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
payload["replay"] = True
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 100
|
||||
""",
|
||||
(last_id,),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/souls")
|
||||
def api_list_souls() -> dict[str, Any]:
|
||||
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
|
||||
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
|
||||
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/souls/{agent_key}/terminal/command")
|
||||
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
|
||||
try:
|
||||
return await execute_terminal_command(agent_key, body.command.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/souls/{agent_key}")
|
||||
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
@router.get("/mesh")
|
||||
def api_agents_mesh() -> dict[str, Any]:
|
||||
souls = agent_souls.list_souls()
|
||||
stats_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS agent_key,
|
||||
MAX(created_at) AS last_event_at,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
|
||||
COUNT(*) FILTER (
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND status IN ('error', 'rejected')
|
||||
) AS errors_24h
|
||||
FROM agent_events
|
||||
GROUP BY LOWER(agent_name)
|
||||
"""
|
||||
)
|
||||
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
|
||||
by_key = _aggregate_agent_stats()
|
||||
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for soul in souls:
|
||||
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
|
||||
row = by_key.get(key, {})
|
||||
events_6h = int(row.get("events_6h") or 0)
|
||||
errors_24h = int(row.get("errors_24h") or 0)
|
||||
health = "offline"
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
health = "healthy"
|
||||
elif events_6h > 0:
|
||||
health = "warn"
|
||||
elif int(soul.get("event_count") or 0) > 0:
|
||||
health = "idle"
|
||||
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
|
||||
is_active = health == "healthy" and events_6h > 0
|
||||
node = dict(soul)
|
||||
node["health"] = health
|
||||
node["events_6h"] = events_6h
|
||||
node["errors_24h"] = errors_24h
|
||||
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
|
||||
node["last_event_at"] = row["last_event_at"].isoformat()
|
||||
node["is_active"] = is_active
|
||||
node["last_event_title"] = row.get("last_event_title")
|
||||
lat = row.get("last_event_at")
|
||||
if lat is not None and hasattr(lat, "isoformat"):
|
||||
node["last_event_at"] = lat.isoformat()
|
||||
nodes.append(node)
|
||||
|
||||
edge_rows = fetch_all(
|
||||
report_edges: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
key = str(node.get("agent_key") or "").lower()
|
||||
if key == "herman" or not node.get("is_active"):
|
||||
continue
|
||||
report_edges.append(
|
||||
{
|
||||
"source": key,
|
||||
"target": "herman",
|
||||
"type": "report",
|
||||
"active": True,
|
||||
"weight": int(node.get("events_6h") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
delegate_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
|
||||
SELECT metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND LOWER(agent_name) <> 'herman'
|
||||
GROUP BY LOWER(agent_name)
|
||||
ORDER BY weight DESC
|
||||
WHERE agent_name = 'herman'
|
||||
AND created_at >= NOW() - INTERVAL '6 hours'
|
||||
AND (
|
||||
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
|
||||
OR metadata ? 'delegated'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
|
||||
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
|
||||
delegate_edges: list[dict[str, Any]] = []
|
||||
seen_delegate: set[tuple[str, str]] = set()
|
||||
for r in delegate_rows:
|
||||
meta = r.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
|
||||
if isinstance(delegated, str):
|
||||
delegated = [delegated]
|
||||
channel = meta.get("channel") or "herman"
|
||||
for agent in delegated:
|
||||
tgt = normalize_agent_key(str(agent))
|
||||
if not tgt or tgt == "herman":
|
||||
continue
|
||||
pair = ("herman", tgt)
|
||||
if pair in seen_delegate:
|
||||
continue
|
||||
seen_delegate.add(pair)
|
||||
delegate_edges.append(
|
||||
{
|
||||
"source": "herman",
|
||||
"target": tgt,
|
||||
"type": "delegate",
|
||||
"active": True,
|
||||
"channel": channel,
|
||||
}
|
||||
)
|
||||
|
||||
peer_static = [
|
||||
{
|
||||
"source": str(r["from_agent"]),
|
||||
"target": str(r["to_agent"]),
|
||||
"type": "static",
|
||||
"handoff_type": r.get("handoff_type"),
|
||||
}
|
||||
for r in agent_integration.list_collaboration()
|
||||
]
|
||||
peer_live = agent_integration.peer_edges_live(hours=6)
|
||||
peer_edges = peer_static + peer_live
|
||||
|
||||
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
|
||||
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
|
||||
executive_edges = []
|
||||
if herman_active:
|
||||
executive_edges = [
|
||||
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
|
||||
{"source": "herman", "target": "cto", "type": "executive", "active": True},
|
||||
]
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"report_edges": report_edges,
|
||||
"delegate_edges": delegate_edges,
|
||||
"peer_edges": peer_edges,
|
||||
"executive_edges": executive_edges,
|
||||
"edges": report_edges,
|
||||
"executives": [
|
||||
{"id": "ceo", "label": "CEO", "role": "Aissa"},
|
||||
{"id": "cto", "label": "CTO", "role": "Platform"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
|
||||
auto = None
|
||||
rid = (req or {}).get("id")
|
||||
if rid:
|
||||
try:
|
||||
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
|
||||
except Exception:
|
||||
auto = None
|
||||
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
|
||||
if auto:
|
||||
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
|
||||
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/approve")
|
||||
@@ -224,3 +545,361 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req}
|
||||
|
||||
|
||||
# --- War Room chat ---
|
||||
class WarroomMessageBody(BaseModel):
|
||||
content: str = Field("", max_length=8000)
|
||||
attachments: list[dict] = Field(default_factory=list)
|
||||
reply_to_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/warroom/rooms")
|
||||
async def warroom_list_rooms() -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
agent_chat.ensure_warroom()
|
||||
return {"items": agent_chat.list_rooms()}
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/dm/{agent_key}")
|
||||
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
try:
|
||||
room = agent_chat.get_or_create_dm(agent_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return {"room": room}
|
||||
|
||||
|
||||
@router.get("/warroom/rooms/{room_id}/messages")
|
||||
async def warroom_list_messages(
|
||||
room_id: int,
|
||||
limit: int = 500,
|
||||
after_id: int | None = None,
|
||||
before_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
|
||||
agent_chat.ensure_warroom()
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
lim = min(max(1, limit), 5000)
|
||||
items = agent_chat.list_messages(
|
||||
room_id, limit=lim, after_id=after_id, before_id=before_id
|
||||
)
|
||||
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
|
||||
total = agent_chat.count_messages(room_id)
|
||||
oldest = agent_chat.oldest_message_id(room_id)
|
||||
first_id = int(items[0]["id"]) if items else None
|
||||
has_more = bool(oldest is not None and first_id is not None and first_id > oldest)
|
||||
return {
|
||||
"ok": True,
|
||||
"room": room,
|
||||
"items": items,
|
||||
"deleted_ids": deleted_ids,
|
||||
"total": total,
|
||||
"has_more": has_more,
|
||||
"oldest_id": oldest,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/messages")
|
||||
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
try:
|
||||
result = await agent_chat.post_user_message(
|
||||
room_id,
|
||||
body.content,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
|
||||
attachments=body.attachments,
|
||||
reply_to_id=body.reply_to_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/messages/stream")
|
||||
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
|
||||
"""SSE stream: user → typing tokens → agent message(s) → done."""
|
||||
import json
|
||||
from app.services import agent_chat
|
||||
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for ev in agent_chat.post_user_message_stream(
|
||||
room_id,
|
||||
body.content,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
|
||||
attachments=body.attachments,
|
||||
reply_to_id=body.reply_to_id,
|
||||
):
|
||||
yield f"data: {json.dumps(ev, default=str)}\n\n"
|
||||
except ValueError as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/warroom/upload")
|
||||
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""Upload a file to share in War Room (images/docs)."""
|
||||
from pathlib import Path as P
|
||||
import uuid
|
||||
import re
|
||||
|
||||
raw_name = (file.filename or "upload.bin").strip()
|
||||
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
|
||||
ext = P(safe).suffix.lower()
|
||||
allowed = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
|
||||
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
|
||||
}
|
||||
if ext not in allowed:
|
||||
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
|
||||
data = await file.read()
|
||||
if len(data) > 12 * 1024 * 1024:
|
||||
raise HTTPException(400, "Max 12MB per bestand")
|
||||
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
|
||||
(dest_dir / fname).write_bytes(data)
|
||||
url = f"/static/uploads/warroom/{fname}"
|
||||
return {
|
||||
"ok": True,
|
||||
"url": url,
|
||||
"name": raw_name[:180],
|
||||
"mime": file.content_type or "application/octet-stream",
|
||||
"size": len(data),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/warroom/digest")
|
||||
async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]:
|
||||
"""Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram."""
|
||||
from app.services import warroom_digest
|
||||
|
||||
try:
|
||||
return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver)
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/continue")
|
||||
async def warroom_continue(request: Request, room_id: int, turns: int = 2):
|
||||
"""Autonomous A2A continuation — agents keep debating with cockpit data."""
|
||||
import json
|
||||
from app.services import agent_chat
|
||||
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for ev in agent_chat.continue_warroom_stream(
|
||||
room_id,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
max_turns=max(1, min(int(turns or 2), 4)),
|
||||
):
|
||||
yield f"data: {json.dumps(ev, default=str)}\n\n"
|
||||
except ValueError as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/warroom/ceo-inbox")
|
||||
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
|
||||
"""Direct messages from agents to CEO (@aissa)."""
|
||||
from app.services import agent_chat
|
||||
|
||||
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
|
||||
return {
|
||||
"ok": True,
|
||||
"items": items,
|
||||
"unread": agent_chat.ceo_inbox_unread_count(),
|
||||
}
|
||||
|
||||
|
||||
class CeoInboxReadBody(BaseModel):
|
||||
ids: Optional[list[int]] = None
|
||||
all: bool = False
|
||||
|
||||
|
||||
@router.post("/warroom/ceo-inbox/read")
|
||||
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
|
||||
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
|
||||
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
|
||||
|
||||
|
||||
@router.delete("/warroom/rooms/{room_id}/messages")
|
||||
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
|
||||
"""Soft-delete all messages in a War Room / DM."""
|
||||
from app.services import agent_chat
|
||||
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
n = agent_chat.clear_messages(room_id)
|
||||
return {"ok": True, "cleared": n}
|
||||
|
||||
|
||||
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
|
||||
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
|
||||
"""Soft-delete a War Room message."""
|
||||
from app.services import agent_chat
|
||||
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
row = agent_chat.delete_message(room_id, message_id)
|
||||
if not row:
|
||||
raise HTTPException(404, "Bericht niet gevonden")
|
||||
return {"ok": True, "id": message_id}
|
||||
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/voice-room")
|
||||
async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]:
|
||||
"""Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS)."""
|
||||
from app.services import agent_chat
|
||||
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
action = str(body.get("action") or "join").lower()
|
||||
agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"]
|
||||
if not isinstance(agents, list):
|
||||
agents = ["herman"]
|
||||
agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12]
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
who = user.get("username") if isinstance(user, dict) else "user"
|
||||
|
||||
if action == "leave":
|
||||
agent_chat.post_system_line(
|
||||
f"🎙️ {who} verliet de Voice Room",
|
||||
agent_key="herman",
|
||||
metadata={"kind": "voice_room", "action": "leave", "opened_by": who},
|
||||
)
|
||||
return {"ok": True, "action": "leave"}
|
||||
|
||||
agent_chat.post_system_line(
|
||||
f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents),
|
||||
agent_key="herman",
|
||||
metadata={
|
||||
"kind": "voice_room",
|
||||
"action": "join",
|
||||
"opened_by": who,
|
||||
"agents": agents,
|
||||
},
|
||||
)
|
||||
return {"ok": True, "action": "join", "agents": agents, "host": who}
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/call")
|
||||
async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]:
|
||||
"""Open a simple no-account video room in an external HTTPS window.
|
||||
|
||||
Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab.
|
||||
Default provider is Freifunk meet (no 8x8 login / no moderator gate).
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
from app.db import execute, fetch_one
|
||||
from app.services import agent_chat
|
||||
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
|
||||
base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/")
|
||||
slug = f"FoodlinkkWR{room_id}"
|
||||
if room.get("kind") == "dm" and room.get("agent_key"):
|
||||
slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}"
|
||||
url = f"{base}/{quote(slug)}"
|
||||
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
who = user.get("username") if isinstance(user, dict) else "user"
|
||||
|
||||
recent = fetch_one(
|
||||
"""SELECT id, metadata FROM warroom_messages
|
||||
WHERE room_id = %s AND deleted_at IS NULL
|
||||
AND metadata->>'kind' = 'call'
|
||||
AND created_at > NOW() - INTERVAL '20 minutes'
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(room_id,),
|
||||
)
|
||||
reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url)
|
||||
|
||||
if not reused:
|
||||
execute(
|
||||
"""UPDATE warroom_messages
|
||||
SET deleted_at = NOW(), content = '',
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb
|
||||
WHERE room_id = %s AND deleted_at IS NULL
|
||||
AND (
|
||||
metadata->>'kind' = 'call'
|
||||
OR content ILIKE %s
|
||||
OR content ILIKE %s
|
||||
)""",
|
||||
(room_id, "%War Room-call%", "%Call open%"),
|
||||
)
|
||||
agent_chat.post_system_line(
|
||||
f"📹 Call open — geen account nodig: {url}",
|
||||
agent_key="herman",
|
||||
metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"url": url,
|
||||
"slug": slug,
|
||||
"provider": "ffmuc",
|
||||
"embed": False,
|
||||
"mode": "external",
|
||||
"reused": reused,
|
||||
"hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.analytics_data import collect_analytics
|
||||
from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
|
||||
class FindingIn(BaseModel):
|
||||
kind: str = Field(default="insight", description="win|opportunity|attention|insight")
|
||||
title: str
|
||||
body: str = ""
|
||||
score: float | None = None
|
||||
source: str = "hermes"
|
||||
href: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def analytics_page(request: Request):
|
||||
initial = collect_analytics({})
|
||||
@@ -30,5 +41,24 @@ async def analytics_api(
|
||||
days: int = Query(90, ge=7, le=365),
|
||||
):
|
||||
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
|
||||
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
|
||||
return JSONResponse(collect_analytics(filters))
|
||||
|
||||
|
||||
@router.get("/api/findings")
|
||||
async def analytics_findings(limit: int = Query(20, ge=1, le=100)):
|
||||
return {"items": list_hermes_findings(limit)}
|
||||
|
||||
|
||||
@router.post("/api/findings")
|
||||
async def analytics_findings_create(payload: FindingIn = Body(...)):
|
||||
"""Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard."""
|
||||
row = add_finding(
|
||||
kind=payload.kind,
|
||||
title=payload.title,
|
||||
body=payload.body,
|
||||
score=payload.score,
|
||||
source=payload.source,
|
||||
href=payload.href,
|
||||
meta=payload.meta,
|
||||
)
|
||||
return {"ok": True, "finding": row}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Login / logout / me + user admin API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import auth_users
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
|
||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
|
||||
|
||||
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
|
||||
raw_name = (file.filename or "avatar.jpg").lower()
|
||||
ext = Path(raw_name).suffix
|
||||
if ext not in ALLOWED_AVATAR:
|
||||
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
|
||||
data = await file.read()
|
||||
if len(data) > 3 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Max 3 MB")
|
||||
# strip old files for this user
|
||||
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
|
||||
path = AVATAR_DIR / fname
|
||||
path.write_bytes(data)
|
||||
return f"/static/uploads/avatars/{fname}"
|
||||
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserCreateBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: Optional[str] = None
|
||||
role: str = "user"
|
||||
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserUpdateBody(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
permissions: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict[str, Any] | None:
|
||||
return getattr(request.state, "user", None)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str = "/"):
|
||||
if _session_user(request):
|
||||
return RedirectResponse(next or "/", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request):
|
||||
form = await request.form()
|
||||
username = str(form.get("username") or "").strip()
|
||||
password = str(form.get("password") or "")
|
||||
next_url = str(form.get("next") or "/")
|
||||
if not next_url.startswith("/"):
|
||||
next_url = "/"
|
||||
user = auth_users.authenticate(username, password)
|
||||
if not user:
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Inloggen",
|
||||
"next": next_url,
|
||||
"error": "Ongeldige gebruikersnaam of wachtwoord",
|
||||
"username": username,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user["id"]
|
||||
request.session["username"] = user["username"]
|
||||
return RedirectResponse(next_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@api_router.post("/login")
|
||||
async def api_login(request: Request, body: LoginBody):
|
||||
user = auth_users.authenticate(body.username, body.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Ongeldige login")
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user["id"]
|
||||
request.session["username"] = user["username"]
|
||||
return {"ok": True, "user": user}
|
||||
|
||||
|
||||
@api_router.post("/logout")
|
||||
async def api_logout(request: Request):
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api_router.get("/me")
|
||||
async def api_me(request: Request):
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
return {
|
||||
"user": user,
|
||||
"pages": auth_users.PAGE_CATALOG,
|
||||
"can_manage_users": auth_users.can_manage_users(user),
|
||||
}
|
||||
|
||||
|
||||
def _require_user_admin(request: Request) -> dict[str, Any]:
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
if not auth_users.can_manage_users(user):
|
||||
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
|
||||
return user
|
||||
|
||||
|
||||
@users_api.get("")
|
||||
async def users_list(request: Request):
|
||||
_require_user_admin(request)
|
||||
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
|
||||
|
||||
|
||||
@users_api.post("")
|
||||
async def users_create(request: Request, body: UserCreateBody):
|
||||
_require_user_admin(request)
|
||||
try:
|
||||
user = auth_users.create_user(
|
||||
body.username, body.password, body.display_name, body.role, body.permissions or {}
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "unique" in msg.lower() or "duplicate" in msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
|
||||
raise HTTPException(status_code=400, detail=msg) from e
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@users_api.put("/{user_id}")
|
||||
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
|
||||
_require_user_admin(request)
|
||||
try:
|
||||
user = auth_users.update_user(
|
||||
user_id,
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
permissions=body.permissions,
|
||||
password=body.password or None,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@users_api.delete("/{user_id}")
|
||||
async def users_delete(request: Request, user_id: int):
|
||||
actor = _require_user_admin(request)
|
||||
try:
|
||||
auth_users.delete_user(user_id, actor_id=actor["id"])
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True}
|
||||
|
||||
@api_router.post("/me/avatar")
|
||||
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
url = await _save_avatar_file(user["id"], file)
|
||||
updated = auth_users.set_avatar(user["id"], url)
|
||||
request.session["username"] = updated["username"]
|
||||
return {"user": updated, "avatar_url": url}
|
||||
|
||||
|
||||
@users_api.post("/{user_id}/avatar")
|
||||
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
|
||||
_require_user_admin(request)
|
||||
url = await _save_avatar_file(user_id, file)
|
||||
updated = auth_users.set_avatar(user_id, url)
|
||||
return {"user": updated, "avatar_url": url}
|
||||
|
||||
|
||||
@users_api.delete("/{user_id}/avatar")
|
||||
async def delete_user_avatar(request: Request, user_id: int):
|
||||
actor = _require_user_admin(request)
|
||||
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
updated = auth_users.set_avatar(user_id, None)
|
||||
return {"user": updated}
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
import json
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services.briefing import collect_briefing_data, serialize_stats
|
||||
from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
@@ -32,8 +32,14 @@ def _safe_sum(table: str, column: str, where: str = "") -> float:
|
||||
|
||||
|
||||
def _briefing_payload(briefing: dict | None) -> dict:
|
||||
"""Always use live DB stats; briefing text may be cached."""
|
||||
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
|
||||
"""Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
|
||||
live_stats = serialize_stats(collect_briefing_data())
|
||||
payload: dict = {
|
||||
"content": None,
|
||||
"stats": live_stats,
|
||||
"live_digest": build_live_digest(live_stats),
|
||||
"created_at": None,
|
||||
}
|
||||
if not briefing:
|
||||
return payload
|
||||
|
||||
@@ -145,7 +151,7 @@ async def dashboard(request: Request):
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Herman · Command Center",
|
||||
"page_title": "CEO Dashboard",
|
||||
"kpis": kpis,
|
||||
"briefing": briefing,
|
||||
"briefing_payload": _briefing_payload(briefing),
|
||||
@@ -158,6 +164,69 @@ async def dashboard(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cto")
|
||||
async def cto_redirect():
|
||||
return RedirectResponse(url="/ops", status_code=302)
|
||||
|
||||
|
||||
@router.get("/cto/dashboard")
|
||||
async def cto_dashboard_legacy(request: Request):
|
||||
kpis = {
|
||||
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
|
||||
"browser_sessions_24h": 0,
|
||||
}
|
||||
try:
|
||||
kpis["browser_sessions_24h"] = _safe_count(
|
||||
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
agent_feed: list = []
|
||||
try:
|
||||
agent_feed = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, created_at
|
||||
FROM agent_events ORDER BY created_at DESC LIMIT 25
|
||||
"""
|
||||
)
|
||||
for ev in agent_feed:
|
||||
if ev.get("created_at"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
except Exception:
|
||||
agent_feed = []
|
||||
|
||||
browser_sessions: list = []
|
||||
try:
|
||||
browser_sessions = fetch_all(
|
||||
"""
|
||||
SELECT id, url, final_url, title, task, status, created_at
|
||||
FROM browser_sessions ORDER BY created_at DESC LIMIT 8
|
||||
"""
|
||||
)
|
||||
for s in browser_sessions:
|
||||
if s.get("created_at"):
|
||||
s["created_at"] = s["created_at"].isoformat()
|
||||
except Exception:
|
||||
browser_sessions = []
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"cto-dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "CTO Dashboard",
|
||||
"kpis": kpis,
|
||||
"agent_feed": agent_feed,
|
||||
"browser_sessions": browser_sessions,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marketing-redirect")
|
||||
async def marketing_redirect():
|
||||
return RedirectResponse(url="/marketing", status_code=302)
|
||||
|
||||
|
||||
@router.get("/trust")
|
||||
async def trust_page(request: Request):
|
||||
return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"})
|
||||
|
||||
@@ -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,331 @@
|
||||
"""Export Intel Cockpit page + API proxy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
BASE = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
||||
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
|
||||
_TILE_CACHE: dict[str, tuple[float, bytes]] = {}
|
||||
_TILE_CACHE_TTL = 86400.0
|
||||
_TILE_CACHE_MAX = 900
|
||||
|
||||
_BUNDLE_CACHE: dict[str, tuple[float, bytes]] = {}
|
||||
_BUNDLE_CACHE_TTL = 120.0
|
||||
|
||||
|
||||
|
||||
async def _proxy(method: str, path: str, **kwargs) -> Any:
|
||||
url = f"{TOOLS}/export-intel{path}"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.request(method, url, **kwargs)
|
||||
if r.status_code >= 400:
|
||||
return {"error": r.text, "status": r.status_code}
|
||||
if "text/csv" in r.headers.get("content-type", ""):
|
||||
return r
|
||||
return r.json()
|
||||
|
||||
|
||||
@router.get("/foodlinkk")
|
||||
def foodlinkk_home():
|
||||
return RedirectResponse(url="/export-intel", status_code=302)
|
||||
|
||||
|
||||
@router.get("/export-intel")
|
||||
def export_intel_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"export_intel.html",
|
||||
{"request": request, "page_title": "Wereldexport"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/stats")
|
||||
async def api_stats(country: Optional[str] = None, region: Optional[str] = None):
|
||||
params = {k: v for k, v in {"country": country, "region": region}.items() if v}
|
||||
return await _proxy("GET", "/stats", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/regions")
|
||||
async def api_regions():
|
||||
return await _proxy("GET", "/regions")
|
||||
|
||||
|
||||
@router.get("/api/export-intel/territories")
|
||||
async def api_territories(region: Optional[str] = None):
|
||||
params = {"region": region} if region else {}
|
||||
return await _proxy("GET", "/territories", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/entities")
|
||||
async def api_entities(
|
||||
country: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_types: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
favorite_only: bool = False,
|
||||
crm_linked: Optional[bool] = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
):
|
||||
params = {k: v for k, v in {
|
||||
"country": country, "region": region, "entity_type": entity_type,
|
||||
"entity_types": entity_types, "q": q, "limit": limit, "offset": offset,
|
||||
"favorite_only": favorite_only, "crm_linked": crm_linked,
|
||||
}.items() if v is not None and v is not False}
|
||||
return await _proxy("GET", "/entities", params=params)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.patch("/api/export-intel/entities/{entity_id}")
|
||||
async def api_update_entity(entity_id: int, request: Request):
|
||||
body = await request.json()
|
||||
return await _proxy("PATCH", f"/entities/{entity_id}", json=body)
|
||||
|
||||
@router.get("/api/export-intel/entities/{entity_id}")
|
||||
async def api_entity(entity_id: int):
|
||||
return await _proxy("GET", f"/entities/{entity_id}")
|
||||
|
||||
|
||||
@router.get("/api/export-intel/contacts")
|
||||
async def api_contacts(
|
||||
country: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
has_email: Optional[bool] = None,
|
||||
crm_linked: Optional[bool] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
):
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if country:
|
||||
params["country"] = country
|
||||
if region:
|
||||
params["region"] = region
|
||||
if entity_type:
|
||||
params["entity_type"] = entity_type
|
||||
if has_email is not None:
|
||||
params["has_email"] = has_email
|
||||
if crm_linked is not None:
|
||||
params["crm_linked"] = crm_linked
|
||||
if q:
|
||||
params["q"] = q
|
||||
return await _proxy("GET", "/contacts", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/contacts/export.csv")
|
||||
async def api_contacts_export(
|
||||
country: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
has_email: Optional[bool] = None,
|
||||
crm_linked: Optional[bool] = None,
|
||||
q: Optional[str] = None,
|
||||
):
|
||||
params = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"country": country,
|
||||
"region": region,
|
||||
"entity_type": entity_type,
|
||||
"has_email": has_email,
|
||||
"crm_linked": crm_linked,
|
||||
"q": q,
|
||||
}.items()
|
||||
if v is not None and v != ""
|
||||
}
|
||||
url = f"{TOOLS}/export-intel/contacts/export.csv"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.get(url, params=params)
|
||||
return StreamingResponse(
|
||||
iter([r.content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=export-intel-contacts.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/caterers/brands")
|
||||
async def api_caterer_brands():
|
||||
return await _proxy("GET", "/caterers/brands")
|
||||
|
||||
|
||||
@router.get("/api/export-intel/caterers/presence")
|
||||
async def api_caterer_presence(country: Optional[str] = None):
|
||||
params = {"country": country} if country else {}
|
||||
return await _proxy("GET", "/caterers/presence", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/gov-sources")
|
||||
async def api_gov_sources(country: Optional[str] = None):
|
||||
params = {"country": country} if country else {}
|
||||
return await _proxy("GET", "/gov-sources", params=params)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/map/tiles/{z}/{x}/{y}.png")
|
||||
async def map_tile_proxy(z: int, x: int, y: int) -> Response:
|
||||
"""Proxy Carto dark basemap tiles via cockpit (cached, no client internet needed)."""
|
||||
key = f"{z}/{x}/{y}"
|
||||
hit = _TILE_CACHE.get(key)
|
||||
if hit and time.time() - hit[0] < _TILE_CACHE_TTL:
|
||||
return Response(content=hit[1], media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
|
||||
|
||||
host = ("a", "b", "c", "d")[(x + y) % 4]
|
||||
url = f"https://{host}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
r = await client.get(url)
|
||||
if r.status_code != 200:
|
||||
return Response(status_code=502, content=b"")
|
||||
body = r.content
|
||||
if len(_TILE_CACHE) >= _TILE_CACHE_MAX:
|
||||
oldest = min(_TILE_CACHE.items(), key=lambda item: item[1][0])[0]
|
||||
_TILE_CACHE.pop(oldest, None)
|
||||
_TILE_CACHE[key] = (time.time(), body)
|
||||
return Response(content=body, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
|
||||
except Exception:
|
||||
return Response(status_code=502, content=b"")
|
||||
|
||||
@router.get("/api/export-intel/map/bundle")
|
||||
async def api_map_bundle(
|
||||
country: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_types: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
halal_min: Optional[float] = None,
|
||||
favorite_only: bool = False,
|
||||
crm_linked: Optional[bool] = None,
|
||||
):
|
||||
params = {k: v for k, v in {
|
||||
"country": country, "region": region, "entity_type": entity_type,
|
||||
"entity_types": entity_types, "q": q, "halal_min": halal_min,
|
||||
"favorite_only": favorite_only, "crm_linked": crm_linked,
|
||||
}.items() if v is not None and v is not False}
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
hit = _BUNDLE_CACHE.get(cache_key)
|
||||
if hit and time.time() - hit[0] < _BUNDLE_CACHE_TTL:
|
||||
return Response(content=hit[1], media_type="application/json")
|
||||
|
||||
url = f"{TOOLS}/export-intel/map/bundle"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.get(url, params=params)
|
||||
if r.status_code >= 400:
|
||||
return {"error": r.text, "status": r.status_code}
|
||||
body = r.content
|
||||
if len(_BUNDLE_CACHE) > 32:
|
||||
oldest = min(_BUNDLE_CACHE.items(), key=lambda item: item[1][0])[0]
|
||||
_BUNDLE_CACHE.pop(oldest, None)
|
||||
_BUNDLE_CACHE[cache_key] = (time.time(), body)
|
||||
return Response(content=body, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/api/export-intel/entities/favorites")
|
||||
async def api_set_favorites(request: Request):
|
||||
body = await request.json()
|
||||
return await _proxy("POST", "/entities/favorites", json=body)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/entities/favorites")
|
||||
async def api_list_favorites(country: Optional[str] = None, region: Optional[str] = None, limit: int = 200):
|
||||
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
|
||||
return await _proxy("GET", "/entities/favorites", params=params)
|
||||
|
||||
|
||||
@router.post("/api/export-intel/crm/push")
|
||||
async def api_crm_push(request: Request):
|
||||
body = await request.json()
|
||||
return await _proxy("POST", "/crm/push", json=body)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/crm/pipeline")
|
||||
async def api_crm_pipeline(country: Optional[str] = None, region: Optional[str] = None, limit: int = 100):
|
||||
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
|
||||
return await _proxy("GET", "/crm/pipeline", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/halal/markets")
|
||||
async def api_halal_markets(
|
||||
region: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
limit: int = 30,
|
||||
):
|
||||
params = {k: v for k, v in {"region": region, "country": country, "limit": limit}.items() if v is not None}
|
||||
return await _proxy("GET", "/halal/markets", params=params)
|
||||
|
||||
|
||||
@router.get("/api/export-intel/tenders")
|
||||
async def api_tenders(country: Optional[str] = None, status: Optional[str] = "open", limit: int = 100):
|
||||
params = {k: v for k, v in {"country": country, "status": status, "limit": limit}.items() if v is not None}
|
||||
return await _proxy("GET", "/tenders", params=params)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/export-intel/settings")
|
||||
async def api_export_intel_settings_get():
|
||||
return await _proxy("GET", "/settings")
|
||||
|
||||
|
||||
@router.put("/api/export-intel/settings")
|
||||
async def api_export_intel_settings_put(request: Request):
|
||||
body = await request.json()
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.put(f"{TOOLS}/export-intel/settings", json=body)
|
||||
return r.json()
|
||||
|
||||
|
||||
@router.post("/api/export-intel/sync/places")
|
||||
async def api_sync_places(request: Request):
|
||||
body = {}
|
||||
if request.headers.get("content-type", "").startswith("application/json"):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
async with httpx.AsyncClient(timeout=7200.0) as client:
|
||||
r = await client.post(f"{TOOLS}/export-intel/sync/places", json=body)
|
||||
return r.json()
|
||||
|
||||
|
||||
|
||||
@router.post("/api/export-intel/sync/cancel")
|
||||
async def api_sync_cancel():
|
||||
return await _proxy("POST", "/sync/cancel")
|
||||
|
||||
@router.get("/api/export-intel/sync/status")
|
||||
async def api_sync_status():
|
||||
return await _proxy("GET", "/sync/status")
|
||||
|
||||
@router.post("/api/export-intel/sync/{kind}")
|
||||
async def api_sync(kind: str, request: Request):
|
||||
body = {}
|
||||
if request.headers.get("content-type", "").startswith("application/json"):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region", "missing", "places"):
|
||||
return {"error": "unknown sync kind"}
|
||||
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
url = f"{TOOLS}/export-intel/sync/{kind}"
|
||||
r = await client.post(url, json=body)
|
||||
return r.json()
|
||||
@@ -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
|
||||
@@ -1,7 +1,10 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
from app import auth_users
|
||||
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -10,12 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(request: Request, tab: str = "email"):
|
||||
allowed = ("email", "general", "permissions", "social")
|
||||
allowed = ("email", "general", "themes", "permissions", "social", "users")
|
||||
active = tab if tab in allowed else "email"
|
||||
if tab == "ai":
|
||||
active = "email"
|
||||
user = getattr(request.state, "user", None)
|
||||
if active == "users" and not auth_users.can_manage_users(user):
|
||||
return RedirectResponse("/settings?tab=email", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Settings",
|
||||
"active_tab": tab if tab in allowed else "email",
|
||||
"active_tab": active,
|
||||
"can_manage_users": auth_users.can_manage_users(user),
|
||||
"current_user": user,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -381,3 +381,119 @@ def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
|
||||
def grant_all_permissions() -> dict[str, Any]:
|
||||
n = agent_souls.grant_all_permissions()
|
||||
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
|
||||
|
||||
|
||||
# ── LLM providers ──────────────────────────────────────────────────────────
|
||||
|
||||
from app.services import llm_router
|
||||
|
||||
|
||||
class LlmProviderBody(BaseModel):
|
||||
label: str = Field(..., max_length=128)
|
||||
provider_type: str = Field(default="deepseek", max_length=48)
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model: str = Field(default="deepseek-chat", max_length=128)
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
extra_config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@settings_router.get("/llm/presets")
|
||||
def llm_presets() -> dict[str, Any]:
|
||||
return {"presets": llm_router.list_presets()}
|
||||
|
||||
|
||||
@settings_router.get("/llm")
|
||||
def llm_list() -> dict[str, Any]:
|
||||
llm_router.ensure_openrouter_placeholder()
|
||||
items = llm_router.list_providers()
|
||||
default = next((i for i in items if i.get("is_default")), items[0] if items else None)
|
||||
return {"providers": items, "default": default, "presets": llm_router.list_presets()}
|
||||
|
||||
|
||||
@settings_router.post("/llm")
|
||||
def llm_create(body: LlmProviderBody) -> dict[str, Any]:
|
||||
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
|
||||
base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
|
||||
model = body.model or preset.get("default_model") or (preset.get("models") or ["deepseek-chat"])[0]
|
||||
if body.is_default:
|
||||
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||||
row = fetch_one(
|
||||
"""INSERT INTO llm_providers (
|
||||
label, provider_type, api_base_url, api_key, model,
|
||||
is_active, is_default, extra_config, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||
RETURNING *""",
|
||||
(
|
||||
body.label,
|
||||
body.provider_type,
|
||||
base_url,
|
||||
body.api_key or "",
|
||||
model,
|
||||
body.is_active,
|
||||
body.is_default,
|
||||
json.dumps(body.extra_config or {}),
|
||||
),
|
||||
)
|
||||
return {"provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.put("/llm/{provider_id}")
|
||||
def llm_update(provider_id: int, body: LlmProviderBody) -> dict[str, Any]:
|
||||
existing = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if not existing:
|
||||
raise HTTPException(404, "Provider niet gevonden")
|
||||
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
|
||||
base_url = (body.api_base_url or preset.get("api_base_url") or existing.get("api_base_url") or "").strip() or None
|
||||
api_key = body.api_key if body.api_key else existing.get("api_key") or ""
|
||||
if body.is_default:
|
||||
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||||
row = fetch_one(
|
||||
"""UPDATE llm_providers SET
|
||||
label = %s, provider_type = %s, api_base_url = %s, api_key = %s, model = %s,
|
||||
is_active = %s, is_default = %s, extra_config = %s::jsonb, updated_at = NOW()
|
||||
WHERE id = %s RETURNING *""",
|
||||
(
|
||||
body.label,
|
||||
body.provider_type,
|
||||
base_url,
|
||||
api_key,
|
||||
body.model,
|
||||
body.is_active,
|
||||
body.is_default,
|
||||
json.dumps(body.extra_config or {}),
|
||||
provider_id,
|
||||
),
|
||||
)
|
||||
return {"provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.delete("/llm/{provider_id}")
|
||||
def llm_delete(provider_id: int) -> dict[str, Any]:
|
||||
row = fetch_one("SELECT is_default FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Provider niet gevonden")
|
||||
execute("DELETE FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if row.get("is_default"):
|
||||
execute(
|
||||
"""UPDATE llm_providers SET is_default = TRUE, updated_at = NOW()
|
||||
WHERE id = (SELECT id FROM llm_providers ORDER BY id LIMIT 1)"""
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@settings_router.post("/llm/{provider_id}/activate")
|
||||
def llm_activate(provider_id: int) -> dict[str, Any]:
|
||||
llm_router.set_default(provider_id)
|
||||
row = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
return {"ok": True, "provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.post("/llm/{provider_id}/test")
|
||||
async def llm_test(provider_id: int) -> dict[str, Any]:
|
||||
ok, msg = await llm_router.test_provider(provider_id)
|
||||
if not ok:
|
||||
raise HTTPException(502, msg)
|
||||
return {"ok": True, "message": msg}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,3 +191,48 @@ def require_approved(request_id: int) -> dict[str, Any]:
|
||||
if req.get("status") != "approved":
|
||||
raise PermissionError(f"Request status is {req.get('status')}, approval required")
|
||||
return req
|
||||
|
||||
|
||||
def _auto_approve_policy() -> dict[str, bool]:
|
||||
try:
|
||||
row = fetch_one("SELECT value FROM app_settings WHERE key = 'sysops_auto_approve'")
|
||||
if row and row.get("value"):
|
||||
val = row["value"]
|
||||
if isinstance(val, str):
|
||||
return json.loads(val)
|
||||
return dict(val)
|
||||
except Exception:
|
||||
pass
|
||||
return {"maintenance_scan": True, "config_backup": True}
|
||||
|
||||
|
||||
def try_auto_approve_and_execute(request_id: int) -> dict[str, Any] | None:
|
||||
"""Auto-approve low-risk SysOps requests when policy allows."""
|
||||
req = get_request(request_id)
|
||||
if not req or req.get("status") != "pending":
|
||||
return None
|
||||
key = str(req.get("agent_key") or "").lower()
|
||||
action = str(req.get("action_type") or "")
|
||||
policy = _auto_approve_policy()
|
||||
if key != "sysops" or not policy.get(action):
|
||||
return None
|
||||
approved = approve_request(request_id, approved_by="auto_policy")
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
result_payload: dict[str, Any] = {}
|
||||
try:
|
||||
if action == "config_backup":
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
resp = client.post(f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id})
|
||||
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
|
||||
elif action == "maintenance_scan":
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.post(f"{tools_url}/ops/maintenance/scan")
|
||||
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
|
||||
except Exception as exc:
|
||||
result_payload = {"ok": False, "detail": str(exc)}
|
||||
executed = mark_executed(request_id, result_payload)
|
||||
return {"approved": approved, "executed": executed, "auto": True}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,145 @@
|
||||
"""Agent handoffs and collaboration matrix."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
elif k == "correlation_id" and v is not None:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def list_collaboration() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"SELECT from_agent, to_agent, handoff_type, description FROM agent_collaboration ORDER BY from_agent, to_agent"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_handoff(
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
*,
|
||||
handoff_type: str = "partner",
|
||||
payload: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
) -> dict[str, Any]:
|
||||
src = normalize_agent_key(from_agent)
|
||||
dst = normalize_agent_key(to_agent)
|
||||
if not src or not dst:
|
||||
raise ValueError("from_agent and to_agent required")
|
||||
cid = correlation_id or str(uuid.uuid4())
|
||||
try:
|
||||
uuid.UUID(str(cid))
|
||||
except ValueError:
|
||||
cid = str(uuid.uuid4())
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO agent_handoffs (correlation_id, from_agent, to_agent, handoff_type, payload, status, completed_at)
|
||||
VALUES (%s::uuid, %s, %s, %s, %s::jsonb, %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END)
|
||||
RETURNING *
|
||||
""",
|
||||
(cid, src, dst, handoff_type, json.dumps(payload or {}), status, status),
|
||||
)
|
||||
handoff = _serialize(row) or {}
|
||||
meta = {
|
||||
"correlation_id": cid,
|
||||
"handoff_id": handoff.get("id"),
|
||||
"target_agent": dst,
|
||||
"source_agent": src,
|
||||
"handoff_type": handoff_type,
|
||||
}
|
||||
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
|
||||
try:
|
||||
from app.services import agent_chat
|
||||
agent_chat.announce_handoff(src, dst, handoff_type, correlation_id=cid)
|
||||
except Exception:
|
||||
pass
|
||||
return handoff
|
||||
|
||||
|
||||
def _log_handoff_events(
|
||||
src: str,
|
||||
dst: str,
|
||||
handoff_type: str,
|
||||
payload: dict[str, Any],
|
||||
meta: dict[str, Any],
|
||||
cid: str,
|
||||
) -> None:
|
||||
title_out = f"{src} → {dst}: {handoff_type}"
|
||||
title_in = f"Handoff van {src}: {handoff_type}"
|
||||
body = json.dumps(payload)[:2000] if payload else ""
|
||||
for agent, etype, title, extra in (
|
||||
(src, "handoff_out", title_out, {"target_agent": dst}),
|
||||
(dst, "handoff_in", title_in, {"source_agent": src}),
|
||||
):
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
agent,
|
||||
"agent_handoff",
|
||||
etype,
|
||||
title[:255],
|
||||
body,
|
||||
"completed",
|
||||
"agents",
|
||||
json.dumps({**meta, **extra}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def recent_handoffs(hours: int = 6) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT * FROM agent_handoffs
|
||||
WHERE created_at >= NOW() - make_interval(hours => %s)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(max(1, min(hours, 168)),),
|
||||
)
|
||||
return [_serialize(r) for r in rows if r]
|
||||
|
||||
|
||||
def peer_edges_live(hours: int = 6) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT from_agent, to_agent, handoff_type, COUNT(*) AS weight,
|
||||
MAX(correlation_id::text) AS correlation_id
|
||||
FROM agent_handoffs
|
||||
WHERE created_at >= NOW() - make_interval(hours => %s)
|
||||
AND status = 'completed'
|
||||
GROUP BY from_agent, to_agent, handoff_type
|
||||
""",
|
||||
(max(1, min(hours, 168)),),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"source": str(r["from_agent"]),
|
||||
"target": str(r["to_agent"]),
|
||||
"type": "live",
|
||||
"handoff_type": r.get("handoff_type"),
|
||||
"weight": int(r["weight"] or 1),
|
||||
"correlation_id": r.get("correlation_id"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Normalize legacy agent_name values to agent_souls keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
AGENT_NAME_MAP: dict[str, str] = {
|
||||
"retail_360": "retail",
|
||||
"retail_scraper": "retail",
|
||||
"retail_crm": "retail",
|
||||
"retail_intel": "retail",
|
||||
"rss_feeds": "marketing",
|
||||
"wholesale_scraper": "sourcing",
|
||||
"halal_registry": "halal",
|
||||
"halal_engine": "halal",
|
||||
"halal_reco": "halal",
|
||||
"branch_scraper": "sourcing",
|
||||
"hermes": "herman",
|
||||
"herman_delegate": "herman",
|
||||
"ops": "sysops",
|
||||
"sysop": "sysops",
|
||||
"web_builder": "webbuilder",
|
||||
"packaging_agent": "packaging",
|
||||
}
|
||||
|
||||
|
||||
def normalize_agent_key(name: str | None) -> str:
|
||||
key = (name or "").strip().lower()
|
||||
if not key:
|
||||
return ""
|
||||
return AGENT_NAME_MAP.get(key, key)
|
||||
|
||||
|
||||
def aliases_for_key(canonical: str) -> list[str]:
|
||||
"""All raw agent_name values that map to this soul key (including the key itself)."""
|
||||
key = (canonical or "").strip().lower()
|
||||
if not key:
|
||||
return []
|
||||
names = {key}
|
||||
for raw, canon in AGENT_NAME_MAP.items():
|
||||
if canon == key:
|
||||
names.add(raw)
|
||||
return sorted(names)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
|
||||
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
|
||||
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
|
||||
|
||||
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
|
||||
|
||||
|
||||
def slugify(name: str, max_len: int = 40) -> str:
|
||||
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
|
||||
return (s[:max_len] or "file")
|
||||
|
||||
|
||||
def root() -> Path:
|
||||
return AGENTS_NAS_ROOT
|
||||
|
||||
|
||||
def unc() -> str:
|
||||
return AGENTS_NAS_UNC
|
||||
|
||||
|
||||
def smb() -> str:
|
||||
return AGENTS_NAS_SMB
|
||||
|
||||
|
||||
def ensure_root() -> Path:
|
||||
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
sys_dir = AGENTS_NAS_ROOT / "_system"
|
||||
sys_dir.mkdir(parents=True, exist_ok=True)
|
||||
readme = sys_dir / "README.txt"
|
||||
if not readme.exists():
|
||||
readme.write_text(
|
||||
"Foodlinkk Agents NAS workspace\n"
|
||||
f"Pad: {AGENTS_NAS_SMB}\n"
|
||||
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
|
||||
"Submappen: notes, exports, memory, inbox, outbox\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
meta = {
|
||||
"unc": AGENTS_NAS_UNC,
|
||||
"smb": AGENTS_NAS_SMB,
|
||||
"container_path": str(AGENTS_NAS_ROOT),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
(sys_dir / "workspace.json").write_text(
|
||||
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return AGENTS_NAS_ROOT
|
||||
|
||||
|
||||
def ensure_agent_folder(agent_key: str) -> Path:
|
||||
ensure_root()
|
||||
key = slugify(agent_key or "unknown", 32).lower()
|
||||
base = AGENTS_NAS_ROOT / key
|
||||
for sub in AGENT_SUBDIRS:
|
||||
(base / sub).mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"agent_key": key,
|
||||
"path": str(base),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}",
|
||||
"subdirs": list(AGENT_SUBDIRS),
|
||||
}
|
||||
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return base
|
||||
|
||||
|
||||
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
|
||||
ensure_root()
|
||||
keys = agent_keys or []
|
||||
created = []
|
||||
for k in keys:
|
||||
p = ensure_agent_folder(str(k))
|
||||
created.append(str(p))
|
||||
return {
|
||||
"ok": True,
|
||||
"root": str(AGENTS_NAS_ROOT),
|
||||
"smb": AGENTS_NAS_SMB,
|
||||
"agents": created,
|
||||
"count": len(created),
|
||||
}
|
||||
|
||||
|
||||
def memory_rule_text() -> str:
|
||||
return (
|
||||
"NAS DATA (HARDCODED — VERPLICHT):\n"
|
||||
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
|
||||
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
|
||||
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
|
||||
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
|
||||
"inbox/ (inkomend), outbox/ (uitgaand).\n"
|
||||
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
|
||||
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
|
||||
)
|
||||
|
||||
|
||||
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
|
||||
base = ensure_agent_folder(agent_key)
|
||||
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
|
||||
rel = relative_path.lstrip("/\\").replace("..", "")
|
||||
if not rel:
|
||||
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
|
||||
target = (base / sub / rel).resolve()
|
||||
if not str(target).startswith(str(base.resolve())):
|
||||
raise ValueError("Pad buiten agent-map niet toegestaan")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
|
||||
key = slugify(agent_key).lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(target),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
|
||||
}
|
||||
|
||||
|
||||
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
|
||||
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
|
||||
return write_text(
|
||||
agent_key,
|
||||
name,
|
||||
json.dumps(data, indent=2, ensure_ascii=False, default=str),
|
||||
subdir=subdir,
|
||||
)
|
||||
|
||||
|
||||
def agent_paths(agent_key: str) -> dict[str, str]:
|
||||
key = slugify(agent_key or "unknown", 32).lower()
|
||||
return {
|
||||
"root": str(AGENTS_NAS_ROOT / key),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}",
|
||||
}
|
||||
@@ -1,49 +1,169 @@
|
||||
"""Agent soul profiles and Herman permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.agent_names import aliases_for_key, normalize_agent_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] += 1
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
bucket["current_status"] = r.get("status")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age_ok = True
|
||||
try:
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24):
|
||||
bucket["events_24h"] += 1
|
||||
if str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
except Exception:
|
||||
age_ok = False
|
||||
_ = age_ok
|
||||
# Also count older totals beyond 30d window lightly
|
||||
try:
|
||||
totals = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, COUNT(*)::int AS c
|
||||
FROM agent_events
|
||||
GROUP BY agent_name
|
||||
"""
|
||||
)
|
||||
total_by: dict[str, int] = {}
|
||||
for r in totals:
|
||||
k = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not k:
|
||||
continue
|
||||
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
|
||||
for k, c in total_by.items():
|
||||
bucket = by_key.setdefault(
|
||||
k,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] = c
|
||||
except Exception:
|
||||
pass
|
||||
return by_key
|
||||
|
||||
|
||||
def list_souls() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""SELECT s.*,
|
||||
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
|
||||
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
|
||||
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_task,
|
||||
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_status,
|
||||
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_event_type
|
||||
FROM agent_souls s ORDER BY s.display_name"""
|
||||
rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name")
|
||||
stats = _aggregate_event_stats()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
soul = dict(r)
|
||||
key = str(soul.get("agent_key") or "").lower()
|
||||
st = stats.get(key, {})
|
||||
events_6h = int(st.get("events_6h") or 0)
|
||||
errors_24h = int(st.get("errors_24h") or 0)
|
||||
event_count = int(st.get("event_count") or 0)
|
||||
health = _node_health(events_6h, errors_24h, event_count)
|
||||
lat = st.get("last_event_at")
|
||||
if lat is not None and hasattr(lat, "isoformat"):
|
||||
lat = lat.isoformat()
|
||||
soul["event_count"] = event_count
|
||||
soul["events_6h"] = events_6h
|
||||
soul["events_24h"] = int(st.get("events_24h") or 0)
|
||||
soul["errors_24h"] = errors_24h
|
||||
soul["health"] = health
|
||||
soul["last_event_at"] = lat
|
||||
soul["current_task"] = st.get("last_event_title") or soul.get("current_task")
|
||||
soul["current_status"] = st.get("current_status")
|
||||
# Working = recent activity (not just DB is_active flag)
|
||||
soul["working"] = events_6h > 0
|
||||
# is_active souls are never "offline" in the registry (War Room / mesh ready)
|
||||
if not soul.get("is_active", True):
|
||||
soul["activity"] = "offline"
|
||||
else:
|
||||
soul["activity"] = (
|
||||
"busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5)
|
||||
else ("active" if events_6h > 0 else "idle")
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
out.append(soul)
|
||||
return out
|
||||
|
||||
|
||||
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one(
|
||||
"""SELECT s.*,
|
||||
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count
|
||||
FROM agent_souls s WHERE agent_key = %s""",
|
||||
(agent_key.lower(),),
|
||||
)
|
||||
row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),))
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
key = agent_key.lower()
|
||||
stats = _aggregate_event_stats().get(key, {})
|
||||
out["event_count"] = int(stats.get("event_count") or 0)
|
||||
out["events_6h"] = int(stats.get("events_6h") or 0)
|
||||
out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"])
|
||||
out["recent_events"] = list_agent_events(agent_key, limit=15)
|
||||
return out
|
||||
|
||||
|
||||
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
names = aliases_for_key(agent_key)
|
||||
if not names:
|
||||
return []
|
||||
placeholders = ",".join(["%s"] * len(names))
|
||||
rows = fetch_all(
|
||||
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
||||
f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
WHERE LOWER(agent_name) IN ({placeholders})
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s""",
|
||||
(agent_key.lower(), limit),
|
||||
(*[n.lower() for n in names], limit),
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Interactive agent terminal — parse and execute whitelisted commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent
|
||||
from app.services.agent_events_log import log_agent_event
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
|
||||
|
||||
Line = dict[str, Any]
|
||||
|
||||
GLOBAL_HELP = [
|
||||
"help — dit overzicht",
|
||||
"status — huidige status & taak",
|
||||
"history — laatste events (in terminal)",
|
||||
"handoff <agent> <type> [notitie]",
|
||||
"say <bericht> — log een notitie",
|
||||
"ask <vraag> — AI-antwoord in rol van deze agent",
|
||||
"nas — toon Agents NAS-pad",
|
||||
"save <bestand> <tekst> — schrijf naar NAS Agents/<agent>/notes/",
|
||||
]
|
||||
|
||||
AGENT_HELP: dict[str, list[str]] = {
|
||||
"herman": [
|
||||
"briefing — dagrapport genereren",
|
||||
"<tekst> — vraag / opdracht aan Herman",
|
||||
],
|
||||
"browser": [
|
||||
"monitor add <url> — site toevoegen aan monitor",
|
||||
"monitor list — actieve sites",
|
||||
"browse <url> — pagina openen",
|
||||
"crawl — alle sites crawlen + parse-overzicht",
|
||||
"parse — laatste parse-resultaten",
|
||||
"parse <id> — parse van specifieke site",
|
||||
"intel — hype & trend analyse (samenvatting)",
|
||||
"intel <zoekterm> — filter analyse",
|
||||
],
|
||||
"research": ["run — research pipeline starten", "briefs — recente briefs"],
|
||||
"retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
|
||||
"sysops": [
|
||||
"backup — config backup (approval)",
|
||||
"scan — maintenance scan",
|
||||
"topology — infra overzicht",
|
||||
"status — sysops status",
|
||||
],
|
||||
"marketing": ["reco — aanbevelingen genereren"],
|
||||
"packaging": ["design <brief> — packaging opdracht (via Herman)"],
|
||||
"webbuilder": [
|
||||
"build <opdracht> — website bouwen (agy op Hermes)",
|
||||
"projects — bestaande website-projecten",
|
||||
"status [project] — build-status",
|
||||
"preview [project] — preview-URL",
|
||||
],
|
||||
"email": ["sync — inbox sync trigger"],
|
||||
"finance": ["margins — marge-overzicht (placeholder)"],
|
||||
}
|
||||
|
||||
|
||||
def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
|
||||
return {"type": line_type, "message": msg, "detail": detail}
|
||||
|
||||
|
||||
def _help_lines(key: str) -> list[Line]:
|
||||
lines = [_line("Beschikbare commando's:", line_type="output")]
|
||||
for h in GLOBAL_HELP:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
extra = AGENT_HELP.get(key, [])
|
||||
if extra:
|
||||
lines.append(_line(f"— {key} —", line_type="output"))
|
||||
for h in extra:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
|
||||
recent = soul.get("recent_events") or []
|
||||
last = recent[0] if recent else {}
|
||||
lines = [
|
||||
_line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
|
||||
_line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
|
||||
_line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
|
||||
_line(f"Events totaal: {soul.get('event_count') or 0}"),
|
||||
]
|
||||
resp = (soul.get("responsibilities") or "").strip()
|
||||
if resp:
|
||||
lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
|
||||
return lines
|
||||
|
||||
|
||||
async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "detail": resp.text[:500]}
|
||||
if resp.status_code >= 400:
|
||||
data.setdefault("ok", False)
|
||||
data.setdefault("detail", resp.text[:500])
|
||||
return data
|
||||
|
||||
|
||||
async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{TOOLS_URL}{path}")
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
|
||||
dst = normalize_agent_key(dst_raw)
|
||||
if not dst:
|
||||
return [_line("Onbekende agent: " + dst_raw, line_type="error")]
|
||||
payload = {"note": note} if note else {}
|
||||
try:
|
||||
agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
|
||||
return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
|
||||
except Exception as exc:
|
||||
return [_line("Handoff mislukt: " + str(exc), line_type="error")]
|
||||
|
||||
|
||||
async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
|
||||
name = soul.get("display_name") or key
|
||||
role = soul.get("role_title") or ""
|
||||
prompt = (
|
||||
f"Je bent {name} ({key}), {role}. "
|
||||
f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
|
||||
)
|
||||
result = await herman.chat(prompt, channel=f"terminal:{key}")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_out",
|
||||
reply[:255] if reply else "Antwoord",
|
||||
reply[:4000],
|
||||
channel="terminal",
|
||||
metadata={"delegated": delegated, "question": question[:500]},
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
|
||||
if result.get("message") and not result.get("results"):
|
||||
return [_line(result["message"], line_type="output")]
|
||||
lines: list[Line] = [
|
||||
_line(
|
||||
f"Crawl klaar — {result.get('sites', 0)} site(s), "
|
||||
f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
|
||||
)
|
||||
]
|
||||
for r in result.get("results") or []:
|
||||
if r.get("status") == "ERROR":
|
||||
lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
|
||||
continue
|
||||
flag = "△" if r.get("changed") else "✓"
|
||||
lines.append(
|
||||
_line(
|
||||
f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
|
||||
f"{r.get('links_count', 0)} links",
|
||||
detail=(r.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
headings = r.get("headings") or []
|
||||
if headings:
|
||||
lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
|
||||
for link in (r.get("links_sample") or [])[:3]:
|
||||
lbl = link.get("label") or link.get("href") or ""
|
||||
lines.append(_line(" → " + lbl[:70], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
|
||||
if not items:
|
||||
return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
|
||||
lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
|
||||
for p in items:
|
||||
lines.append(
|
||||
_line(
|
||||
f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
|
||||
f"{p.get('word_count', 0)} woorden",
|
||||
detail=(p.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
if p.get("meta_description"):
|
||||
lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
|
||||
for h in (p.get("headings") or [])[:3]:
|
||||
lines.append(_line(" · " + h[:90], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
|
||||
if key == "herman":
|
||||
if cmd == "briefing":
|
||||
try:
|
||||
content = await herman.generate_briefing()
|
||||
preview = (content or "")[:400]
|
||||
log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
|
||||
return [_line("CEO briefing gegenereerd.", detail=preview)]
|
||||
except Exception as exc:
|
||||
return [_line("Briefing mislukt: " + str(exc), line_type="error")]
|
||||
result = await herman.chat(raw, channel="terminal")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
|
||||
return lines
|
||||
|
||||
if key == "browser":
|
||||
if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
|
||||
url = args[1]
|
||||
from app.services.monitor import add_site
|
||||
|
||||
site = add_site(url, name=url[:80])
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_site_added",
|
||||
f"Terminal: site toegevoegd {url[:120]}",
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"url": url, "site_id": site.get("id")},
|
||||
)
|
||||
return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
|
||||
if cmd == "monitor" and args and args[0].lower() == "list":
|
||||
rows = fetch_all(
|
||||
"SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
|
||||
)
|
||||
if not rows:
|
||||
return [_line("Geen monitor-sites.")]
|
||||
return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
|
||||
if cmd == "browse" and args:
|
||||
url = args[0]
|
||||
data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
|
||||
log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
|
||||
title = data.get("title") or data.get("url") or url
|
||||
return [_line(f"Browse OK: {title}")]
|
||||
if cmd == "crawl":
|
||||
from app.services.monitor import trigger_crawl
|
||||
|
||||
site_id = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
result = trigger_crawl(site_id)
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_crawl",
|
||||
f"Terminal crawl ({result.get('sites', 0)} sites)",
|
||||
json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
|
||||
channel="terminal",
|
||||
)
|
||||
return _format_crawl_results(result)
|
||||
if cmd == "parse":
|
||||
from app.services.monitor import list_parse_results
|
||||
|
||||
sid = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
return _format_parse_items(list_parse_results(site_id=sid, limit=10))
|
||||
if cmd == "intel":
|
||||
from app.services.monitor import build_parse_intelligence
|
||||
|
||||
q = " ".join(args) if args else None
|
||||
data = build_parse_intelligence(query=q, limit=20)
|
||||
lines = [
|
||||
_line(
|
||||
f"Analyse: {data['summary']['pages']} pagina's · "
|
||||
f"{data['summary']['total_words']} woorden · "
|
||||
f"{data['summary']['themes_detected']} trend-termen"
|
||||
),
|
||||
_line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
|
||||
]
|
||||
for t in (data.get("hype_terms") or [])[:12]:
|
||||
flag = "★ " if t.get("cross_site") else ""
|
||||
lines.append(
|
||||
_line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
|
||||
)
|
||||
return lines
|
||||
|
||||
if key == "research":
|
||||
if cmd == "run":
|
||||
data = await _tools_post("/research/run")
|
||||
ok = data.get("ok", True)
|
||||
log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
|
||||
return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
|
||||
if cmd == "briefs":
|
||||
data = await _tools_get("/research/briefs")
|
||||
items = data if isinstance(data, list) else data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen briefs.")]
|
||||
return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
|
||||
|
||||
if key == "retail":
|
||||
if cmd == "rss":
|
||||
data = await _tools_post("/retail/rss/refresh")
|
||||
log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
|
||||
return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
|
||||
if cmd == "scores":
|
||||
data = await _tools_post("/retail/compute-opportunities")
|
||||
return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
|
||||
|
||||
if key == "sysops":
|
||||
if cmd == "backup":
|
||||
data = await _tools_post("/ops/backup/run")
|
||||
log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
|
||||
return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "scan":
|
||||
data = await _tools_post("/ops/maintenance/scan")
|
||||
return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "topology":
|
||||
data = await _tools_get("/ops/topology")
|
||||
return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
|
||||
if cmd == "status":
|
||||
data = await _tools_get("/ops/status")
|
||||
return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
|
||||
|
||||
if key == "marketing" and cmd == "reco":
|
||||
data = await _tools_post("/recommendations/generate")
|
||||
return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
|
||||
|
||||
if key == "packaging" and cmd == "design":
|
||||
brief = " ".join(args) or raw
|
||||
result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
return [_line(reply, detail=reply)]
|
||||
|
||||
if key == "webbuilder":
|
||||
if cmd == "build":
|
||||
brief = rest or raw
|
||||
if not brief:
|
||||
return [_line("Gebruik: build <opdracht>", line_type="error")]
|
||||
try:
|
||||
outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
|
||||
proj = outcome.get("project") or "?"
|
||||
preview = outcome.get("preview_url") or "—"
|
||||
return [
|
||||
_line(f"Build gestart: {proj}"),
|
||||
_line(f"Preview: {preview}", line_type="output"),
|
||||
_line("Volg voortgang live in dit terminal-venster.", line_type="output"),
|
||||
]
|
||||
except Exception as exc:
|
||||
return [_line("Build mislukt: " + str(exc), line_type="error")]
|
||||
if cmd == "projects":
|
||||
data = await webbuilder_agent.list_projects()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen projecten op Hermes/NAS.")]
|
||||
return [
|
||||
_line(
|
||||
f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
|
||||
+ (" · index.html ✓" if p.get("has_index") else "")
|
||||
)
|
||||
for p in items
|
||||
]
|
||||
if cmd == "status":
|
||||
slug = args[0] if args else ""
|
||||
if not slug:
|
||||
return [_line("Gebruik: status <project>", line_type="error")]
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
|
||||
tail = (st.get("log_tail") or "")[:300]
|
||||
if tail:
|
||||
lines.append(_line(tail, line_type="output"))
|
||||
return lines
|
||||
if cmd == "preview":
|
||||
slug = args[0] if args else ""
|
||||
if slug:
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
else:
|
||||
url = webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
return [_line(f"Preview: {url}")]
|
||||
|
||||
return await _agent_ask(key, soul, raw)
|
||||
|
||||
|
||||
async def execute_terminal_command(
|
||||
agent_key: str,
|
||||
command: str,
|
||||
*,
|
||||
issued_by: str = "ceo",
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise ValueError("Agent niet gevonden")
|
||||
|
||||
raw = (command or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Leeg commando")
|
||||
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_in",
|
||||
raw[:255],
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"issued_by": issued_by, "command": raw[:500]},
|
||||
)
|
||||
|
||||
parts = raw.split()
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:]
|
||||
rest = " ".join(args)
|
||||
|
||||
if cmd in ("help", "?", "h"):
|
||||
lines = _help_lines(key)
|
||||
elif cmd == "status":
|
||||
lines = _status_lines(key, soul)
|
||||
elif cmd == "handoff":
|
||||
if len(args) < 2:
|
||||
lines = [_line("Gebruik: handoff <agent> <type> [notitie]", line_type="error")]
|
||||
else:
|
||||
note = " ".join(args[2:]) if len(args) > 2 else ""
|
||||
lines = _handoff(key, args[0], args[1], note)
|
||||
elif cmd == "nas":
|
||||
try:
|
||||
agent_nas.ensure_agent_folder(key)
|
||||
paths = agent_nas.agent_paths(key)
|
||||
lines = [
|
||||
_line("Agents NAS: " + str(agent_nas.smb())),
|
||||
_line("Jouw map: " + str(paths.get("smb") or "")),
|
||||
_line("Container: " + str(paths.get("root") or "")),
|
||||
_line("Sub: notes/ exports/ memory/ inbox/ outbox/"),
|
||||
]
|
||||
except Exception as exc:
|
||||
lines = [_line("NAS fout: " + str(exc), line_type="error")]
|
||||
elif cmd == "save":
|
||||
if len(args) < 2:
|
||||
lines = [_line("Gebruik: save <bestandsnaam> <tekst>", line_type="error")]
|
||||
else:
|
||||
fname = args[0]
|
||||
body = " ".join(args[1:])
|
||||
try:
|
||||
result = agent_nas.write_text(key, fname, body, subdir="notes")
|
||||
lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))]
|
||||
except Exception as exc:
|
||||
lines = [_line("Save mislukt: " + str(exc), line_type="error")]
|
||||
elif cmd == "say":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: say <bericht>", line_type="error")]
|
||||
else:
|
||||
log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
|
||||
lines = [_line("Genoteerd.")]
|
||||
elif cmd == "ask":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: ask <vraag>", line_type="error")]
|
||||
else:
|
||||
lines = await _agent_ask(key, soul, rest)
|
||||
elif cmd == "history":
|
||||
events = agent_souls.list_agent_events(key, limit=8)
|
||||
if not events:
|
||||
lines = [_line("Geen history.")]
|
||||
else:
|
||||
lines = []
|
||||
for ev in reversed(events):
|
||||
t = (ev.get("created_at") or "")[11:19] or "--:--"
|
||||
lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
|
||||
else:
|
||||
lines = await _run_agent_command(key, soul, cmd, args, raw)
|
||||
|
||||
return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Comprehensive analytics data from all DB tables with optional filters."""
|
||||
"""Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
|
||||
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
|
||||
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
|
||||
return rows
|
||||
|
||||
|
||||
def ensure_findings_table() -> None:
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS analytics_findings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'insight',
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
score DOUBLE PRECISION,
|
||||
source TEXT DEFAULT 'hermes',
|
||||
href TEXT,
|
||||
meta JSONB DEFAULT '{}'::jsonb,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]:
|
||||
ensure_findings_table()
|
||||
try:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, kind, title, body, score, source, href, meta, created_at
|
||||
FROM analytics_findings
|
||||
WHERE active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return _iso_rows(rows or [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def add_finding(
|
||||
*,
|
||||
kind: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
score: float | None = None,
|
||||
source: str = "hermes",
|
||||
href: str | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ensure_findings_table()
|
||||
import json
|
||||
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO analytics_findings (kind, title, body, score, source, href, meta)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id, kind, title, body, score, source, href, meta, created_at
|
||||
""",
|
||||
(
|
||||
kind or "insight",
|
||||
title,
|
||||
body or "",
|
||||
score,
|
||||
source or "hermes",
|
||||
href,
|
||||
json.dumps(meta or {}),
|
||||
),
|
||||
)
|
||||
return _iso_rows([row])[0] if row else {}
|
||||
|
||||
|
||||
def _revenue_kpis() -> dict[str, Any]:
|
||||
out = {
|
||||
"active_projects": 0,
|
||||
"margin_month": 0.0,
|
||||
"margin_year": 0.0,
|
||||
"vision_text": "",
|
||||
"horizon_text": "",
|
||||
"tagline": "",
|
||||
}
|
||||
try:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c,
|
||||
COALESCE(SUM(margin_month),0) AS mm,
|
||||
COALESCE(SUM(margin_year),0) AS my
|
||||
FROM revenue_projects
|
||||
WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done')
|
||||
"""
|
||||
)
|
||||
if row:
|
||||
out["active_projects"] = int(row.get("c") or 0)
|
||||
out["margin_month"] = float(row.get("mm") or 0)
|
||||
out["margin_year"] = float(row.get("my") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
g = fetch_one(
|
||||
"""
|
||||
SELECT vision_text, horizon_text, tagline, mid_text
|
||||
FROM revenue_cockpit_goals
|
||||
WHERE COALESCE(is_active, TRUE) = TRUE
|
||||
ORDER BY updated_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if g:
|
||||
out["vision_text"] = g.get("vision_text") or ""
|
||||
out["horizon_text"] = g.get("horizon_text") or ""
|
||||
out["tagline"] = g.get("tagline") or ""
|
||||
out["mid_text"] = g.get("mid_text") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _build_pulse(data: dict[str, Any]) -> dict[str, Any]:
|
||||
k = data.get("kpis") or {}
|
||||
partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])}
|
||||
active_p = int(partners.get("active") or k.get("crm_partnerships") or 0)
|
||||
none_p = int(partners.get("none") or 0)
|
||||
proposal_p = int(partners.get("proposal") or 0)
|
||||
stores = int(k.get("supermarkets") or 0)
|
||||
cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0
|
||||
gap = max(stores - active_p - proposal_p, 0)
|
||||
|
||||
wins: list[dict[str, Any]] = []
|
||||
opportunities: list[dict[str, Any]] = []
|
||||
attention: list[dict[str, Any]] = []
|
||||
|
||||
if stores >= 1000:
|
||||
wins.append({
|
||||
"id": "win-coverage",
|
||||
"title": "Retail footprint is massief",
|
||||
"body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."),
|
||||
"score": 92,
|
||||
"metric": f"{stores} stores",
|
||||
"href": "/retail",
|
||||
})
|
||||
if active_p >= 10:
|
||||
wins.append({
|
||||
"id": "win-crm",
|
||||
"title": "Actieve partnerships draaien",
|
||||
"body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.",
|
||||
"score": 78,
|
||||
"metric": f"{active_p} actief",
|
||||
"href": "/retail",
|
||||
})
|
||||
if int(k.get("rss_items") or 0) >= 200:
|
||||
wins.append({
|
||||
"id": "win-rss",
|
||||
"title": "Markt-signalen stromen binnen",
|
||||
"body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.",
|
||||
"score": 74,
|
||||
"metric": f"{int(k['rss_items'])} items",
|
||||
"href": "/marketing",
|
||||
})
|
||||
if int(k.get("promo_campaigns") or 0) >= 10:
|
||||
wins.append({
|
||||
"id": "win-promo",
|
||||
"title": "Promo-intelligence actief",
|
||||
"body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.",
|
||||
"score": 70,
|
||||
"metric": f"{int(k['promo_campaigns'])} campagnes",
|
||||
"href": "/marketing",
|
||||
})
|
||||
if float(k.get("margin_month") or 0) > 0:
|
||||
wins.append({
|
||||
"id": "win-margin",
|
||||
"title": "Revenue engine levert marge",
|
||||
"body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."),
|
||||
"score": 88,
|
||||
"metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."),
|
||||
"href": "/revenue-cockpit",
|
||||
})
|
||||
if int(k.get("wholesalers") or 0) >= 100:
|
||||
wins.append({
|
||||
"id": "win-wholesale",
|
||||
"title": "Groothandel-netwerk in kaart",
|
||||
"body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.",
|
||||
"score": 68,
|
||||
"metric": f"{int(k['wholesalers'])} GH",
|
||||
"href": "/retail",
|
||||
})
|
||||
if int(k.get("agent_events") or 0) >= 500:
|
||||
wins.append({
|
||||
"id": "win-agents",
|
||||
"title": "Agent-mesh is productief",
|
||||
"body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.",
|
||||
"score": 66,
|
||||
"metric": f"{int(k['agent_events'])} events",
|
||||
"href": "/agents",
|
||||
})
|
||||
|
||||
if gap >= 100:
|
||||
opportunities.append({
|
||||
"id": "opp-white-space",
|
||||
"title": "Enorm witruimte in retail CRM",
|
||||
"body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."),
|
||||
"score": min(98, 40 + int(gap / 100)),
|
||||
"metric": f"{gap} open",
|
||||
"href": "/retail",
|
||||
"tag": "distributie",
|
||||
})
|
||||
if cover_pct < 5:
|
||||
opportunities.append({
|
||||
"id": "opp-cover",
|
||||
"title": "Partnership-dekking is nog dun",
|
||||
"body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.",
|
||||
"score": 90,
|
||||
"metric": f"{cover_pct}% dekking",
|
||||
"href": "/retail",
|
||||
"tag": "groei",
|
||||
})
|
||||
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
|
||||
opportunities.append({
|
||||
"id": "opp-clients",
|
||||
"title": "Klanten in intake → nog niet actief",
|
||||
"body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.",
|
||||
"score": 82,
|
||||
"metric": "0 active",
|
||||
"href": "/clients",
|
||||
"tag": "crm",
|
||||
})
|
||||
if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0:
|
||||
opportunities.append({
|
||||
"id": "opp-pipeline",
|
||||
"title": "Deals zonder pipeline-waarde",
|
||||
"body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.",
|
||||
"score": 76,
|
||||
"metric": "€0 pipeline",
|
||||
"href": "/deals",
|
||||
"tag": "sales",
|
||||
})
|
||||
|
||||
top_opp = data.get("top_opportunities") or []
|
||||
if top_opp:
|
||||
best = top_opp[0]
|
||||
opportunities.append({
|
||||
"id": "opp-halal-top",
|
||||
"title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}",
|
||||
"body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.",
|
||||
"score": round(float(best.get("halal_opportunity_score") or 0)),
|
||||
"metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}",
|
||||
"href": "/retail",
|
||||
"tag": "halal",
|
||||
})
|
||||
# cluster cities
|
||||
cities: dict[str, int] = {}
|
||||
for o in top_opp:
|
||||
cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1
|
||||
top_city = sorted(cities.items(), key=lambda x: -x[1])[0]
|
||||
if top_city[1] >= 2:
|
||||
opportunities.append({
|
||||
"id": "opp-city-cluster",
|
||||
"title": f"Cluster-kans in {top_city[0]}",
|
||||
"body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.",
|
||||
"score": 72,
|
||||
"metric": top_city[0],
|
||||
"href": "/retail",
|
||||
"tag": "geo",
|
||||
})
|
||||
|
||||
if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0:
|
||||
opportunities.append({
|
||||
"id": "opp-gh-contacts",
|
||||
"title": "Groothandel-contacten ontbreken",
|
||||
"body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.",
|
||||
"score": 69,
|
||||
"metric": "0 GH-contacten",
|
||||
"href": "/retail",
|
||||
"tag": "netwerk",
|
||||
})
|
||||
|
||||
if int(k.get("pending_approvals") or 0) > 0:
|
||||
attention.append({
|
||||
"id": "att-approvals",
|
||||
"title": "Goedkeuringen wachten",
|
||||
"body": f"{int(k['pending_approvals'])} agent-items op needs_approval.",
|
||||
"score": 55,
|
||||
"metric": str(int(k["pending_approvals"])),
|
||||
"href": "/agents",
|
||||
})
|
||||
if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100:
|
||||
attention.append({
|
||||
"id": "att-bookmarks",
|
||||
"title": "Geen RSS-bookmarks",
|
||||
"body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.",
|
||||
"score": 48,
|
||||
"metric": "0 bookmarks",
|
||||
"href": "/marketing",
|
||||
})
|
||||
|
||||
# Pulse score 0-100
|
||||
score = 35
|
||||
score += min(18, active_p) # up to +18
|
||||
score += 12 if stores >= 2000 else (6 if stores >= 500 else 0)
|
||||
score += 8 if int(k.get("rss_items") or 0) >= 500 else 0
|
||||
score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0)
|
||||
score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0
|
||||
score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0
|
||||
if cover_pct < 1:
|
||||
score -= 12
|
||||
elif cover_pct < 3:
|
||||
score -= 6
|
||||
if int(k.get("pending_approvals") or 0) >= 5:
|
||||
score -= 5
|
||||
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
|
||||
score -= 4
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= 75:
|
||||
label, tone = "Sterk momentum", "good"
|
||||
elif score >= 55:
|
||||
label, tone = "Stabiel · kansen open", "mid"
|
||||
else:
|
||||
label, tone = "Opbouw-fase · focus nodig", "low"
|
||||
|
||||
wins.sort(key=lambda x: -int(x.get("score") or 0))
|
||||
opportunities.sort(key=lambda x: -int(x.get("score") or 0))
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"label": label,
|
||||
"tone": tone,
|
||||
"cover_pct": cover_pct,
|
||||
"white_space": gap,
|
||||
"wins": wins[:8],
|
||||
"opportunities": opportunities[:8],
|
||||
"attention": attention[:6],
|
||||
"headline": "Wat goed gaat vs waar de kansen liggen",
|
||||
"sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.",
|
||||
}
|
||||
|
||||
|
||||
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
f = filters or {}
|
||||
chain = f.get("chain") or None
|
||||
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
"filters": f,
|
||||
}
|
||||
|
||||
rev = _revenue_kpis()
|
||||
data["kpis"] = {
|
||||
"clients_total": _safe_count("clients"),
|
||||
"clients_active": _safe_count("clients", "stage = 'active'"),
|
||||
"deals_total": _safe_count("deals"),
|
||||
"pipeline_eur": float(
|
||||
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
|
||||
or 0
|
||||
),
|
||||
"supermarkets": _safe_count("supermarkets"),
|
||||
"wholesalers": _safe_count("wholesalers"),
|
||||
@@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
"contacts_supermarket": _safe_count("supermarket_contacts"),
|
||||
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
|
||||
"nas_docs": _safe_count("document_analytics"),
|
||||
"active_projects": rev.get("active_projects") or 0,
|
||||
"margin_month": rev.get("margin_month") or 0,
|
||||
"margin_year": rev.get("margin_year") or 0,
|
||||
}
|
||||
data["goals"] = {
|
||||
"vision_text": rev.get("vision_text") or "",
|
||||
"horizon_text": rev.get("horizon_text") or "",
|
||||
"tagline": rev.get("tagline") or "",
|
||||
"mid_text": rev.get("mid_text") or "",
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
try:
|
||||
data["events_by_agent"] = fetch_all(
|
||||
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '%s days'
|
||||
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["events_by_agent"] = []
|
||||
@@ -100,10 +449,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
data["supermarkets_by_chain"] = []
|
||||
|
||||
try:
|
||||
if store_where:
|
||||
data["supermarkets_by_province"] = fetch_all(
|
||||
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
|
||||
if store_where
|
||||
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
|
||||
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12",
|
||||
tuple(store_params),
|
||||
)
|
||||
else:
|
||||
data["supermarkets_by_province"] = fetch_all(
|
||||
"SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
|
||||
)
|
||||
except Exception:
|
||||
data["supermarkets_by_province"] = []
|
||||
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
try:
|
||||
data["events_timeline"] = fetch_all(
|
||||
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '%s days'
|
||||
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY DATE(created_at) ORDER BY day ASC""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["events_timeline"] = []
|
||||
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
data["top_opportunities"] = fetch_all(
|
||||
"""SELECT s.chain, s.city, ros.halal_opportunity_score
|
||||
FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id
|
||||
ORDER BY ros.halal_opportunity_score DESC LIMIT 10"""
|
||||
ORDER BY ros.halal_opportunity_score DESC LIMIT 12"""
|
||||
)
|
||||
except Exception:
|
||||
data["top_opportunities"] = []
|
||||
@@ -191,22 +545,20 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
except Exception:
|
||||
data["recent_deals"] = []
|
||||
|
||||
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
|
||||
try:
|
||||
if agent:
|
||||
agent_where += " AND agent_name = %s"
|
||||
try:
|
||||
data["recent_events"] = fetch_all(
|
||||
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
{agent_where} ORDER BY created_at DESC LIMIT 25""",
|
||||
(agent,),
|
||||
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
|
||||
ORDER BY created_at DESC LIMIT 25""",
|
||||
(days, agent),
|
||||
)
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
else:
|
||||
try:
|
||||
data["recent_events"] = fetch_all(
|
||||
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
{agent_where} ORDER BY created_at DESC LIMIT 25"""
|
||||
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
ORDER BY created_at DESC LIMIT 25""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
@@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
):
|
||||
if isinstance(data.get(key), list):
|
||||
data[key] = _iso_rows(data[key])
|
||||
|
||||
data["pulse"] = _build_pulse(data)
|
||||
data["hermes_findings"] = list_hermes_findings(20)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Automatische NAS + second-brain synchronisatie."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
log = logging.getLogger("cockpit.auto_ingest")
|
||||
|
||||
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
|
||||
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
|
||||
|
||||
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
|
||||
(source, status, json.dumps(details)),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("ingest log failed: %s", exc)
|
||||
|
||||
|
||||
def _brain_chat_id_for_client(client_id: int) -> int:
|
||||
return -int(client_id)
|
||||
|
||||
|
||||
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
|
||||
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
|
||||
da.word_count, dl.client_id, c.name AS client_name
|
||||
FROM document_analytics da
|
||||
LEFT JOIN document_links dl ON (
|
||||
da.storage_path = dl.storage_path
|
||||
OR da.storage_path LIKE dl.storage_path || '/%%'
|
||||
) AND dl.is_folder = TRUE
|
||||
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
|
||||
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
|
||||
WHERE da.storage_path IS NOT NULL
|
||||
ORDER BY da.analyzed_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 200)),),
|
||||
)
|
||||
synced = 0
|
||||
errors = 0
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
for row in rows:
|
||||
path = row.get("storage_path") or ""
|
||||
if not path:
|
||||
continue
|
||||
client_id = row.get("client_id")
|
||||
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
|
||||
text = (
|
||||
f"NAS document: {row.get('filename') or path}\n"
|
||||
f"Pad: {path}\nType: {row.get('doc_type')}\n"
|
||||
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
|
||||
)
|
||||
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
|
||||
try:
|
||||
r = await client.post(
|
||||
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"direction": "inbound",
|
||||
"role": "system",
|
||||
"content_type": "document",
|
||||
"content_text": text[:4000],
|
||||
"agent_name": "auto-ingest",
|
||||
"embed": True,
|
||||
"chat_type": "client_brain" if client_id else "nas_corpus",
|
||||
"user_name": row.get("client_name") or "NAS",
|
||||
"content_json": {"storage_path": path, "client_id": client_id},
|
||||
"telegram_message_id": msg_hash,
|
||||
},
|
||||
)
|
||||
if r.status_code < 400:
|
||||
synced += 1
|
||||
else:
|
||||
errors += 1
|
||||
except Exception as exc:
|
||||
log.warning("brain sync %s: %s", path, exc)
|
||||
errors += 1
|
||||
return {"synced": synced, "errors": errors, "candidates": len(rows)}
|
||||
|
||||
|
||||
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
|
||||
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
|
||||
out: dict[str, Any] = {"ok": True, "steps": {}}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
|
||||
params={"force": "true" if force_scan else "false"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
out["steps"]["nas_scan"] = r.json()
|
||||
except Exception as exc:
|
||||
out["steps"]["nas_scan"] = {"error": str(exc)}
|
||||
brain = await sync_nas_to_brain()
|
||||
out["steps"]["brain_sync"] = brain
|
||||
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
|
||||
_log_run("auto_sync", status, out)
|
||||
out["last_logged"] = True
|
||||
return out
|
||||
|
||||
|
||||
def last_auto_sync() -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if hasattr(d.get("created_at"), "isoformat"):
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
if isinstance(d.get("details"), str):
|
||||
try:
|
||||
d["details"] = json.loads(d["details"])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
+447
-157
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import market_stocks, ollama
|
||||
from app.services import market_stocks, llm_router
|
||||
|
||||
|
||||
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
|
||||
@@ -166,6 +169,16 @@ def _collect_pipeline_events(data: dict[str, Any]) -> None:
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
|
||||
try:
|
||||
data["recent_handoffs"] = fetch_all(
|
||||
"""SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
|
||||
FROM agent_handoffs
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY created_at DESC LIMIT 20"""
|
||||
)
|
||||
except Exception:
|
||||
data["recent_handoffs"] = []
|
||||
|
||||
try:
|
||||
data["pending_items"] = fetch_all(
|
||||
"""SELECT agent_name, title, event_type, created_at
|
||||
@@ -230,10 +243,13 @@ def _collect_retail_intel(data: dict[str, Any]) -> None:
|
||||
|
||||
try:
|
||||
data["top_opportunities"] = fetch_all(
|
||||
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
|
||||
"""SELECT s.name, s.chain, s.city, s.address, s.postcode, ros.halal_opportunity_score
|
||||
FROM retail_opportunity_scores ros
|
||||
JOIN supermarkets s ON s.id = ros.supermarket_id
|
||||
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
|
||||
WHERE s.name NOT ILIKE '%masala%'
|
||||
AND s.name NOT ILIKE 'TEST%'
|
||||
AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%'
|
||||
ORDER BY ros.halal_opportunity_score DESC LIMIT 8"""
|
||||
)
|
||||
except Exception:
|
||||
data["top_opportunities"] = []
|
||||
@@ -352,7 +368,57 @@ def _collect_rss_market(data: dict[str, Any]) -> None:
|
||||
data["rss_items"] = _safe_count("rss_items")
|
||||
|
||||
|
||||
def collect_briefing_data() -> dict[str, Any]:
|
||||
def _classify_revenue_brand(name: str) -> str:
|
||||
n = (name or "").lower()
|
||||
if any(k in n for k in ("foodlinkk", "linknbit", "linknbi", "wereldexport")):
|
||||
return "foodlinkk"
|
||||
if "cucina" in n and "foodlinkk" not in n:
|
||||
return "cucina"
|
||||
chains = ("ah", "jumbo", "picnic", "plus", "vomar", "dirk", "hoogvliet", "deka", "spar", "doner")
|
||||
if any(n == c or n.startswith(c + " ") for c in chains):
|
||||
return "cucina"
|
||||
if any(k in n for k in ("foodservice", "snack", "diepvries", "halal", "gas stunning", "marketing budget adds cucina")):
|
||||
return "cucina"
|
||||
if any(k in n for k in ("fresh supplier", "boerschappen", "subsidies cucina")):
|
||||
return "foodlinkk"
|
||||
if any(k in n for k in ("total earnings", "loonkosten", "total expenses", "verkopen cucina")):
|
||||
return "shared"
|
||||
return "other"
|
||||
|
||||
|
||||
def _collect_revenue_cockpit(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
from app.services import revenue_cockpit as rc
|
||||
|
||||
projects = rc.list_projects()
|
||||
stats = rc.dashboard_stats()
|
||||
by_brand: dict[str, list] = {"cucina": [], "foodlinkk": [], "shared": [], "other": []}
|
||||
for p in projects:
|
||||
if (p.get("status") or "") != "active":
|
||||
continue
|
||||
brand = _classify_revenue_brand(p.get("name") or "")
|
||||
by_brand[brand].append(p)
|
||||
data["revenue"] = {
|
||||
"stats": stats,
|
||||
"by_brand": {
|
||||
k: sorted(v, key=lambda x: float(x.get("margin_month") or 0), reverse=True)[:6]
|
||||
for k, v in by_brand.items()
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
data["revenue"] = {}
|
||||
|
||||
|
||||
def _collect_export_intel(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/stats"
|
||||
r = httpx.get(url, timeout=4.0)
|
||||
data["export_intel"] = r.json() if r.status_code == 200 else {}
|
||||
except Exception:
|
||||
data["export_intel"] = {}
|
||||
|
||||
|
||||
def collect_briefing_data(*, skip_halal: bool = False) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"date": date.today().isoformat(),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -364,10 +430,37 @@ def collect_briefing_data() -> dict[str, Any]:
|
||||
_collect_nas_analytics(data)
|
||||
_collect_retail_intel(data)
|
||||
_collect_rss_market(data)
|
||||
_collect_revenue_cockpit(data)
|
||||
_collect_export_intel(data)
|
||||
if not skip_halal:
|
||||
_collect_halal_recommendations(data)
|
||||
data["activity_log"] = _build_activity_log(data)
|
||||
return data
|
||||
|
||||
|
||||
def _collect_halal_recommendations(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
url = f"{settings.TOOLS_API_URL.rstrip('/')}/recommendations/halal/live"
|
||||
r = httpx.get(url, params={"limit": 5, "brand": "cucina"}, timeout=20.0)
|
||||
data["halal_recommendations"] = r.json() if r.status_code == 200 else {"items": []}
|
||||
except Exception:
|
||||
data["halal_recommendations"] = {"items": []}
|
||||
# Legacy veld voor backwards compat — eerste 3 engine results
|
||||
items = (data.get("halal_recommendations") or {}).get("items") or []
|
||||
data["top_opportunities"] = [
|
||||
{
|
||||
"id": i.get("store_id"),
|
||||
"name": (i.get("title") or "").split(" · ", 1)[-1],
|
||||
"chain": i.get("chain"),
|
||||
"city": i.get("city"),
|
||||
"halal_opportunity_score": i.get("halal_opportunity_score") or i.get("score"),
|
||||
"composite_score": i.get("score"),
|
||||
"reasons": i.get("reasons"),
|
||||
}
|
||||
for i in items[:5]
|
||||
]
|
||||
|
||||
|
||||
async def stream_daily_briefing():
|
||||
"""Yield SSE step events while building the CEO daily report."""
|
||||
data: dict[str, Any] = {
|
||||
@@ -377,126 +470,77 @@ async def stream_daily_briefing():
|
||||
|
||||
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
|
||||
|
||||
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
|
||||
yield _briefing_step("crm", "CRM", "crm", "running", "Pipeline & klanten ophalen…")
|
||||
_collect_crm_core(data)
|
||||
yield _briefing_step(
|
||||
"crm", "PostgreSQL", "crm", "ok",
|
||||
"crm", "CRM", "crm", "ok",
|
||||
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
|
||||
"tables: clients, deals, products, suppliers",
|
||||
)
|
||||
|
||||
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
|
||||
yield _briefing_step("agents", "Herman", "herman", "running", "Open beslissingen filteren…")
|
||||
_collect_agent_queue(data)
|
||||
data["pending_approval_requests"] = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
data["pending_approvals"] = len(data["pending_approval_requests"])
|
||||
pending = data.get("pending_approval_requests") or []
|
||||
yield _briefing_step(
|
||||
"agents", "PostgreSQL", "herman", "ok",
|
||||
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
|
||||
"table: agent_action_requests",
|
||||
)
|
||||
for row in pending[:4]:
|
||||
agent = row.get("agent_key") or "agent"
|
||||
yield _briefing_step(
|
||||
"agent_msg", "agent_mesh", agent, "agent",
|
||||
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
|
||||
"agents", "Herman", "herman", "ok",
|
||||
f"{len(pending)} relevante beslissingen",
|
||||
)
|
||||
|
||||
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
|
||||
yield _briefing_step("projects", "Operations", "product", "running", "Milestones & projectstatus…")
|
||||
_collect_projects_ops(data)
|
||||
yield _briefing_step(
|
||||
"projects", "PostgreSQL", "sysops", "ok",
|
||||
f"{len(data.get('project_assets_recent') or [])} project assets · "
|
||||
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
|
||||
"tables: project_assets, sysops_activity, config_backups",
|
||||
"projects", "Operations", "product", "ok",
|
||||
f"{len(data.get('milestones_pending') or [])} open milestones",
|
||||
)
|
||||
|
||||
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
|
||||
yield _briefing_step("pipeline", "Finance", "finance", "running", "Pipeline stages laden…")
|
||||
_collect_pipeline_events(data)
|
||||
stages = len(data.get("deals_by_stage") or [])
|
||||
events = data.get("recent_events") or []
|
||||
yield _briefing_step(
|
||||
"pipeline", "PostgreSQL", "finance", "ok",
|
||||
f"{stages} pipeline stages · {len(events)} recente agent-events",
|
||||
"tables: deals, agent_events",
|
||||
"pipeline", "Finance", "finance", "ok",
|
||||
f"{stages} pipeline stages",
|
||||
)
|
||||
|
||||
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
|
||||
# NAS collected for internal stats but not streamed as CEO noise
|
||||
_collect_nas_analytics(data)
|
||||
yield _briefing_step(
|
||||
"nas", "NAS analytics", "knowledge", "ok",
|
||||
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
|
||||
"tables: document_analytics, document_word_counts",
|
||||
)
|
||||
|
||||
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
|
||||
yield _briefing_step("retail", "Retail", "retail", "running", "Supermarkten & partnerships…")
|
||||
_collect_retail_intel(data)
|
||||
opp = data.get("top_opportunities") or []
|
||||
yield _briefing_step(
|
||||
"retail", "Retail 360", "retail", "ok",
|
||||
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
|
||||
f"{len(data.get('milestones_pending') or [])} open milestones",
|
||||
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
|
||||
)
|
||||
if opp:
|
||||
top = opp[0]
|
||||
yield _briefing_step(
|
||||
"agent_msg", "agent_mesh", "retail", "agent",
|
||||
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
|
||||
"retail", "Retail", "retail", "ok",
|
||||
f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · "
|
||||
f"{len(data.get('milestones_pending') or [])} milestones",
|
||||
)
|
||||
|
||||
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
|
||||
yield _briefing_step("rss", "Markt", "marketing", "running", "Food & retail nieuws selecteren…")
|
||||
_collect_rss_market(data)
|
||||
trend_n = len(data.get("trending_food") or [])
|
||||
yield _briefing_step(
|
||||
"rss", "RSS feeds", "marketing", "ok",
|
||||
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
|
||||
"tables: rss_items, rss_feeds, promo_campaigns",
|
||||
)
|
||||
yield _briefing_step(
|
||||
"agent_msg", "agent_mesh", "marketing", "agent",
|
||||
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
|
||||
"rss", "Markt", "marketing", "ok",
|
||||
f"{trend_n} relevante food-headlines",
|
||||
)
|
||||
|
||||
data["activity_log"] = _build_activity_log(data)
|
||||
|
||||
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
|
||||
seen: set[str] = set()
|
||||
mesh_events = data.get("recent_events") or []
|
||||
for ev in mesh_events[:10]:
|
||||
agent = (ev.get("agent_name") or "agent").lower()
|
||||
if agent in seen:
|
||||
continue
|
||||
seen.add(agent)
|
||||
model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent"
|
||||
yield _briefing_step(
|
||||
"agent_msg", "agent_mesh", agent, "agent",
|
||||
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
|
||||
)
|
||||
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
|
||||
yield _briefing_step(
|
||||
"agent_msg", "agent_mesh", "herman", "agent",
|
||||
f"Herman → {agent_key}: briefing context gedeeld",
|
||||
)
|
||||
yield _briefing_step(
|
||||
"agent_mesh", "Agent mesh", "herman", "ok",
|
||||
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
|
||||
)
|
||||
|
||||
yield _briefing_step(
|
||||
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
|
||||
"Herman schrijft executive samenvatting met AI…",
|
||||
"ai", f"Herman ({model_label})", "herman", "running",
|
||||
"Herman schrijft CEO-briefing…",
|
||||
)
|
||||
try:
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
|
||||
except (asyncio.TimeoutError, Exception) as exc:
|
||||
ai_part = ""
|
||||
yield _briefing_step(
|
||||
"ai", "Ollama", "herman", "warn",
|
||||
"AI timeout — gebruik template samenvatting",
|
||||
"ai", "Herman", "herman", "warn",
|
||||
"AI timeout — template samenvatting",
|
||||
str(exc)[:120],
|
||||
)
|
||||
else:
|
||||
yield _briefing_step(
|
||||
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
|
||||
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
|
||||
"ai", f"Herman ({model_label})", "herman", "ok",
|
||||
f"Briefing klaar ({len(ai_part or '')} tekens)",
|
||||
)
|
||||
|
||||
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
|
||||
@@ -517,6 +561,34 @@ async def stream_daily_briefing():
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _fresh_approvals(rows: list | None, *, max_age_days: int = 14) -> list:
|
||||
"""Drop stale agent approvals so SysOps noise does not dominate the CEO digest."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
out = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
||||
for row in rows or []:
|
||||
ts = row.get("created_at") or row.get("requested_at") or row.get("updated_at")
|
||||
if ts is None:
|
||||
continue
|
||||
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
if ts < cutoff:
|
||||
continue
|
||||
except TypeError:
|
||||
continue
|
||||
agent = (row.get("agent_key") or "").lower()
|
||||
action = (row.get("action_type") or "").lower()
|
||||
# Keep business agents; skip routine SysOps unless critical keyword
|
||||
if agent in ("sysops", "ops", "it") or action in ("maintenance_scan", "config_backup", "gitea_sync"):
|
||||
title = (row.get("title") or "").lower()
|
||||
if not any(k in title for k in ("critical", "kritiek", "down", "security", "breach")):
|
||||
continue
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _build_activity_log(data: dict[str, Any]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
@@ -565,14 +637,247 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
|
||||
|
||||
for row in (data.get("recent_handoffs") or [])[:8]:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
lines.append(
|
||||
f"🔗 {row.get('from_agent')} → {row.get('to_agent')}: {row.get('handoff_type')} ({ts_s})"
|
||||
)
|
||||
|
||||
return lines[:25]
|
||||
|
||||
|
||||
def _strip_activity_prefix(line: str) -> str:
|
||||
for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
|
||||
if line.startswith(prefix):
|
||||
return line[len(prefix) :].strip()
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _short_activity(line: str, max_len: int = 88) -> str:
|
||||
s = _strip_activity_prefix(line)
|
||||
s = re.sub(r" — \d{4}-\d{2}-\d{2}$", "", s)
|
||||
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", "", s).strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
if len(s) > max_len:
|
||||
return s[: max_len - 1].rstrip() + "…"
|
||||
return s
|
||||
|
||||
|
||||
def _fmt_eur(val: float | int | None) -> str:
|
||||
try:
|
||||
return f"€{float(val or 0):,.0f}"
|
||||
except (TypeError, ValueError):
|
||||
return "€—"
|
||||
|
||||
|
||||
def _revenue_line(p: dict[str, Any]) -> str:
|
||||
mm = p.get("margin_month")
|
||||
margin = f" · marge/maand {_fmt_eur(mm)}" if mm else ""
|
||||
obj = p.get("open_objectives") or 0
|
||||
extra = f" · {obj} open stappen" if obj else ""
|
||||
return f"{p.get('name')}{margin}{extra}"
|
||||
|
||||
|
||||
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""CEO digest: B2B + B2C + focus + trends + nieuws — geen SysOps."""
|
||||
revenue = data.get("revenue") or {}
|
||||
rev_stats = revenue.get("stats") or {}
|
||||
rev_goals = rev_stats.get("goals") or {}
|
||||
export = data.get("export_intel") or {}
|
||||
market = data.get("market_summary") or {}
|
||||
trends = data.get("market_trends") or []
|
||||
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
|
||||
|
||||
# —— Foodlinkk B2B (compact) ——
|
||||
fl_lines: list[str] = [
|
||||
f"Pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
|
||||
]
|
||||
if export.get("tenders_open") or export.get("distributors"):
|
||||
fl_lines.append(
|
||||
f"Export: {export.get('distributors', 0):,} distributeurs · {export.get('tenders_open', 0)} open tenders"
|
||||
)
|
||||
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
|
||||
fl_projects = sorted(
|
||||
[p for p in fl_projects if p.get("margin_month")],
|
||||
key=lambda p: -float(p.get("margin_month") or 0),
|
||||
)
|
||||
n_rev = 0
|
||||
for p in fl_projects:
|
||||
name_l = (p.get("name") or "").lower()
|
||||
if any(x in name_l for x in ("verkopen", "2030", "total earnings", "total expense", "loonkosten", "boekhouder", "linknbit")):
|
||||
continue
|
||||
fl_lines.append(_revenue_line(p))
|
||||
n_rev += 1
|
||||
if n_rev >= 2:
|
||||
break
|
||||
if export.get("crm_linked"):
|
||||
fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld")
|
||||
|
||||
# —— Cucina B2C (compact) ——
|
||||
cu_lines: list[str] = []
|
||||
if rev_stats:
|
||||
cu_lines.append(
|
||||
f"Marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
|
||||
f"{rev_stats.get('open_objectives', 0)} open stappen"
|
||||
)
|
||||
cu_lines.append(
|
||||
f"Retail: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} partnerships"
|
||||
)
|
||||
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
|
||||
deal_bits = []
|
||||
for p in cu_projects[:4]:
|
||||
name = (p.get("name") or "").strip()
|
||||
if not name or "marketing" in name.lower() or "boekhouder" in name.lower():
|
||||
continue
|
||||
mm = p.get("margin_month")
|
||||
bit = name
|
||||
if mm:
|
||||
bit += f" {_fmt_eur(mm)}/m"
|
||||
if p.get("open_objectives"):
|
||||
bit += f" · {p.get('open_objectives')} stappen"
|
||||
deal_bits.append(bit)
|
||||
if len(deal_bits) >= 3:
|
||||
break
|
||||
if deal_bits:
|
||||
cu_lines.append("Deals: " + " · ".join(deal_bits))
|
||||
if data.get("promo_campaigns"):
|
||||
cu_lines.append(f"Marketing: {data.get('promo_campaigns')} actieve promo's")
|
||||
if halal_items:
|
||||
rec = halal_items[0]
|
||||
cu_lines.append(
|
||||
f"Halal-kans: {rec.get('chain')} · {rec.get('city')} (score {int(float(rec.get('halal_opportunity_score') or rec.get('score') or 0))})"
|
||||
)
|
||||
|
||||
sections = [
|
||||
{"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines},
|
||||
{"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines},
|
||||
]
|
||||
|
||||
# —— Focus vandaag (business only, no sysops) ——
|
||||
focus_lines: list[str] = []
|
||||
for row in (data.get("milestones_pending") or [])[:2]:
|
||||
title = (row.get("title") or "").strip()
|
||||
if title:
|
||||
focus_lines.append(title[:90])
|
||||
for p in cu_projects:
|
||||
if p.get("open_objectives") and (p.get("name") or "").lower() in ("ah", "jumbo", "picnic", "plus"):
|
||||
focus_lines.append(f"Cucina follow-up: {p.get('name')} ({p.get('open_objectives')} open stappen)")
|
||||
break
|
||||
for rec in halal_items[:2]:
|
||||
reason = ((rec.get("reasons") or [""])[0] or "")[:60]
|
||||
focus_lines.append(f"Outreach {rec.get('chain')} · {rec.get('city')}" + (f" — {reason}" if reason else ""))
|
||||
if export.get("tenders_open"):
|
||||
focus_lines.append(f"Bekijk {export.get('tenders_open')} open export-tenders")
|
||||
# Skip sysops / infra approvals entirely
|
||||
if not focus_lines:
|
||||
focus_lines.append("Pipeline en Cucina retail-deals nalopen")
|
||||
sections.append({"brand": "platform", "title": "Focus vandaag", "icon": "🎯", "lines": focus_lines[:4]})
|
||||
|
||||
# —— Trends ——
|
||||
trend_lines: list[str] = []
|
||||
for tr in trends[:4]:
|
||||
name = (tr.get("trend_name") or tr.get("name") or "").strip()
|
||||
score = tr.get("opportunity_score")
|
||||
if not name:
|
||||
continue
|
||||
if score is not None:
|
||||
pct = int(float(score) * 100) if float(score) <= 1 else int(float(score))
|
||||
trend_lines.append(f"{name} ↑{pct}%")
|
||||
else:
|
||||
trend_lines.append(name)
|
||||
if market.get("best_performer"):
|
||||
bp = market["best_performer"]
|
||||
trend_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
|
||||
if not trend_lines:
|
||||
trend_lines.append("Geen trenddata — check Retail 360")
|
||||
|
||||
# —— Nieuws (max 5, food only) ——
|
||||
news: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
skip = ("mediamarkt", "ceconomy", "elektro", "airco", "gaming", "smartphone", "sysops", "gitea", "backup")
|
||||
for row in list(data.get("trending_food") or []) + list(data.get("rss_highlights") or []) + list(data.get("rss_live") or []):
|
||||
title = str(row.get("title") or "").strip()
|
||||
low = title.lower()
|
||||
if not title or title in seen:
|
||||
continue
|
||||
if any(x in low for x in skip):
|
||||
continue
|
||||
seen.add(title)
|
||||
news.append({
|
||||
"title": title[:110],
|
||||
"link": row.get("link") or "#",
|
||||
"source": row.get("feed_name") or "RSS",
|
||||
})
|
||||
if len(news) >= 5:
|
||||
break
|
||||
|
||||
actions = focus_lines[:4]
|
||||
long_term = [
|
||||
"Cucina: schaal AH/Jumbo/PLUS naar actieve partnerships",
|
||||
"Foodlinkk: export-distributeurs koppelen aan CRM-deals",
|
||||
"Revenue: open stappen wekelijks reviewen",
|
||||
]
|
||||
if rev_goals.get("horizon_text"):
|
||||
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
|
||||
|
||||
summary = f"{data.get('date')} · Foodlinkk B2B + Cucina B2C"
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"sections": sections,
|
||||
"b2b": fl_lines,
|
||||
"b2c": cu_lines,
|
||||
"focus": focus_lines[:4],
|
||||
"trends": trend_lines[:5],
|
||||
"news": news,
|
||||
"actions": actions,
|
||||
"long_term": long_term[:4],
|
||||
"updated_at": data.get("generated_at"),
|
||||
"activity_count": len(data.get("activity_log") or []),
|
||||
"source": "live",
|
||||
}
|
||||
|
||||
|
||||
def format_live_digest_text(digest: dict[str, Any]) -> str:
|
||||
"""Platte tekst voor Telegram / Herman chat — halal zit in Cucina-sectie."""
|
||||
lines = ["📋 Herman briefing · live", "", digest.get("summary") or ""]
|
||||
for section in digest.get("sections") or []:
|
||||
icon = section.get("icon") or ""
|
||||
title = section.get("title") or ""
|
||||
lines.append("")
|
||||
lines.append(f"{icon} {title}".strip())
|
||||
for line in section.get("lines") or []:
|
||||
lines.append(f" · {line}")
|
||||
actions = digest.get("actions") or []
|
||||
if actions:
|
||||
lines.extend(["", "⚡ Korte termijn"])
|
||||
for action in actions[:6]:
|
||||
lines.append(f" · {action}")
|
||||
long_term = digest.get("long_term") or []
|
||||
if long_term:
|
||||
lines.extend(["", "🎯 Lange termijn"])
|
||||
for item in long_term[:5]:
|
||||
lines.append(f" · {item}")
|
||||
updated = digest.get("updated_at")
|
||||
if updated:
|
||||
ts = str(updated)[:19].replace("T", " ")
|
||||
n = digest.get("activity_count")
|
||||
extra = f" · {n} activiteiten" if n is not None else ""
|
||||
lines.extend(["", f"Bijgewerkt {ts} UTC{extra}"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_live_digest_text(data: dict[str, Any] | None = None) -> str:
|
||||
payload = data if data is not None else collect_briefing_data()
|
||||
return format_live_digest_text(build_live_digest(payload))
|
||||
|
||||
|
||||
def build_template_report(data: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"# Foodlinkk Dagrapport — {data['date']}",
|
||||
"",
|
||||
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
|
||||
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Herman via OpenCode*",
|
||||
"",
|
||||
"## KPI's",
|
||||
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
|
||||
@@ -622,32 +927,11 @@ def build_template_report(data: dict[str, Any]) -> str:
|
||||
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
|
||||
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
|
||||
|
||||
if data.get("sysops_activity_24h"):
|
||||
lines.extend(["", "## SysOps IT — laatste 24 uur"])
|
||||
for row in data["sysops_activity_24h"][:12]:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
|
||||
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
|
||||
if row.get("body"):
|
||||
lines.append(f" {str(row.get('body'))[:200]}")
|
||||
|
||||
if data.get("pending_approval_requests"):
|
||||
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
|
||||
for row in data["pending_approval_requests"]:
|
||||
action = row.get("action_type") or ""
|
||||
label = "Update-scan VM106" if action == "maintenance_scan" else action
|
||||
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
|
||||
|
||||
if data.get("activity_log"):
|
||||
lines.extend(["", "## Herman activiteitenlog (24u)"])
|
||||
for entry in data["activity_log"][:20]:
|
||||
lines.append(f"- {entry}")
|
||||
|
||||
if data.get("pending_items"):
|
||||
lines.extend(["", "## Legacy goedkeuringen"])
|
||||
for row in data["pending_items"]:
|
||||
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
|
||||
fresh_appr = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
if fresh_appr:
|
||||
lines.extend(["", "## Open beslissingen"])
|
||||
for row in fresh_appr[:8]:
|
||||
lines.append(f"- **{row.get('agent_key')}**: {row.get('title')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -661,73 +945,77 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
|
||||
for row in data.get("milestones_pending") or []:
|
||||
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
|
||||
|
||||
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
|
||||
news_lines = ""
|
||||
for row in (data.get("trending_food") or [])[:5]:
|
||||
title = str(row.get("title") or "").strip()
|
||||
if title:
|
||||
news_lines += f"- {title}\n"
|
||||
|
||||
fresh = _fresh_approvals(data.get("pending_approval_requests") or [])
|
||||
decision_lines = ""
|
||||
for row in fresh[:5]:
|
||||
decision_lines += f"- @{row.get('agent_key')}: {row.get('title') or row.get('action_type')}\n"
|
||||
|
||||
prompt = (
|
||||
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
|
||||
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
|
||||
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
|
||||
"## Lange termijn focus\n(3-5 bullets)\n\n"
|
||||
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
|
||||
f"Data vandaag ({data['date']}):\n"
|
||||
"Schrijf een strak CEO-memo in het Nederlands (markdown) voor Aïssa (Foodlinkk / Cucina, "
|
||||
"halal kant-en-klaar). Geen IT, geen SysOps, geen NAS, geen agent-mesh.\n\n"
|
||||
"## Stand van zaken\n(4-6 zinnen: business context vandaag)\n\n"
|
||||
"## Pipeline & revenue\n(bullets met cijfers en wat dat betekent)\n\n"
|
||||
"## Retail & partnerships\n(supermarkten, milestones, halal-kansen)\n\n"
|
||||
"## Markt\n(max 3 relevante food/retail signalen)\n\n"
|
||||
"## Beslissingen vandaag\n(3-5 concrete CEO-acties)\n\n"
|
||||
f"Data ({data['date']}):\n"
|
||||
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
|
||||
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
|
||||
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
|
||||
f"Activiteitenlog:\n{activity}\n"
|
||||
f"- {data.get('supermarkets',0)} filialen, {data.get('crm_partnerships',0)} partnerships\n"
|
||||
f"Top kansen:\n{opp_lines or '- geen data'}\n"
|
||||
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
|
||||
f"Milestones:\n{ms_lines or '- geen'}\n"
|
||||
f"Markt:\n{news_lines or '- geen'}\n"
|
||||
f"Open beslissingen:\n{decision_lines or '- geen'}\n"
|
||||
)
|
||||
system = (
|
||||
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
|
||||
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
|
||||
"Schrijf warm, professioneel, actionable."
|
||||
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf als een executive briefing: "
|
||||
"helder, zakelijk, actionable. Focus op omzet, retail, partnerships en keuzes. "
|
||||
"Noem geen servers, backups, Gitea, NAS of SysOps. "
|
||||
"Antwoord UITSLUITEND met de markdown-memo. Geen voorwoord, geen bestandsnamen, "
|
||||
"geen vragen aan de gebruiker, geen tools of Telegram-aanbod."
|
||||
)
|
||||
try:
|
||||
return await ollama.generate(prompt, system=system, timeout=120.0)
|
||||
raw = await llm_router.generate(prompt, system=system, timeout=120.0)
|
||||
except Exception:
|
||||
return ""
|
||||
text = (raw or "").strip()
|
||||
for marker in ("## Stand van zaken", "## Samenvatting", "# "):
|
||||
idx = text.find(marker)
|
||||
if idx > 0:
|
||||
text = text[idx:]
|
||||
break
|
||||
cut_at = len(text)
|
||||
for m in ["Wil je dat ik", "Wil je deze", "Shall I", "Memo geschreven"]:
|
||||
idx = text.find(m)
|
||||
if 0 < idx < cut_at:
|
||||
cut_at = idx
|
||||
text = text[:cut_at].rstrip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _fallback_summary(data: dict[str, Any]) -> str:
|
||||
opp = data.get("top_opportunities") or []
|
||||
ms = data.get("milestones_pending") or []
|
||||
lines = [
|
||||
"## Samenvatting",
|
||||
f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
|
||||
f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
|
||||
]
|
||||
if opp:
|
||||
top = opp[0]
|
||||
lines.append(
|
||||
f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
|
||||
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
|
||||
)
|
||||
digest = build_live_digest(data)
|
||||
lines = ["## Samenvatting", digest["summary"]]
|
||||
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
|
||||
actions = [
|
||||
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
|
||||
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
|
||||
"Check Marketing Live Feed voor kant-en-klaar trends",
|
||||
]
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
if row.get("action_type") == "maintenance_scan":
|
||||
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
|
||||
break
|
||||
if ms:
|
||||
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
|
||||
for a in actions[:6]:
|
||||
for a in digest["actions"]:
|
||||
lines.append(f"- {a}")
|
||||
lines.extend(["", "## Lange termijn focus"])
|
||||
lines.extend([
|
||||
"- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
|
||||
"- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
|
||||
"- Wekelijks milestones review in Retail 360 sales tab",
|
||||
])
|
||||
for a in digest["long_term"]:
|
||||
lines.append(f"- {a}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _save_briefing(content: str, data: dict[str, Any]) -> None:
|
||||
safe = serialize_stats(data)
|
||||
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
|
||||
metadata = {"stats": safe, "model": getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent", "type": "daily_ceo_report"}
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
|
||||
@@ -765,7 +1053,7 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
|
||||
data = collect_briefing_data()
|
||||
template = build_template_report(data)
|
||||
try:
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
|
||||
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
ai_part = ""
|
||||
|
||||
@@ -775,4 +1063,6 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
|
||||
content = _fallback_summary(data) + "\n\n---\n\n" + template
|
||||
|
||||
_save_briefing(content, data)
|
||||
return content, serialize_stats(data)
|
||||
stats = serialize_stats(data)
|
||||
stats["live_digest"] = build_live_digest(data)
|
||||
return content, stats
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
+276
-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,201 @@ def _pick_agent(raw: str) -> str:
|
||||
return k
|
||||
return "knowledge"
|
||||
|
||||
async def chat(message: str) -> dict[str, Any]:
|
||||
def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dict] | None = None) -> list[dict[str, str]]:
|
||||
steps: list[dict[str, str]] = []
|
||||
for a in delegated:
|
||||
key = (a or "").strip().lower()
|
||||
if key and key != "herman":
|
||||
steps.append({"agent": key, "status": "delegated", "message": routing_reason or "Aangestuurd door Herman"})
|
||||
if extra:
|
||||
steps.extend(extra)
|
||||
return steps
|
||||
|
||||
|
||||
|
||||
async def _chat_via_llm_router(
|
||||
message: str,
|
||||
channel: str,
|
||||
*,
|
||||
username: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from app.services import hermes_memory, llm_router
|
||||
|
||||
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel)
|
||||
from app.config import settings as _settings
|
||||
display = "Aïssa" if str(hermes_user) == str(_settings.HERMES_CEO_CHAT_ID) else (username or "gebruiker")
|
||||
system = hermes_memory.continuity_system(hermes_user, display_name=display)
|
||||
|
||||
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
|
||||
feed_items = await hermes_memory.fetch_recent_feed(hermes_user)
|
||||
hist = hermes_memory.feed_to_messages(feed_items)
|
||||
if hist:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"Recente Telegram-geschiedenis voor chat_id={hermes_user} "
|
||||
f"({len(hist)} berichten, oud→nieuw). Behandel dit als jouw geheugencontext."
|
||||
),
|
||||
})
|
||||
messages.extend(hist)
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
reply, meta = await llm_router.chat_messages(messages, timeout=180.0, user=hermes_user)
|
||||
provider = meta.get("provider_label") or "LLM"
|
||||
model = meta.get("model") or ""
|
||||
await _log_event(
|
||||
"herman",
|
||||
"llm_chat",
|
||||
f"Herman via {provider}",
|
||||
message[:2000],
|
||||
{
|
||||
"provider": meta,
|
||||
"channel": channel,
|
||||
"hermes_user": hermes_user,
|
||||
"memory_messages": len(hist),
|
||||
"username": username,
|
||||
},
|
||||
channel=channel,
|
||||
)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": f"Herman · {provider}",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["herman"],
|
||||
"routing_reason": (
|
||||
f"Cloud LLM · {provider}"
|
||||
+ (f" · {model}" if model else "")
|
||||
+ f" · memory:{hermes_user}"
|
||||
+ (f" · feed:{len(hist)}" if hist else "")
|
||||
),
|
||||
"agent_steps": _agent_steps([], f"Via {provider} + Hermes memory"),
|
||||
"llm_provider": meta,
|
||||
"hermes_user": hermes_user,
|
||||
"memory_messages": len(hist),
|
||||
}
|
||||
|
||||
|
||||
async def chat(
|
||||
message: str,
|
||||
channel: str = "cockpit",
|
||||
session_id: str | None = None,
|
||||
confirm_action_id: str | None = None,
|
||||
username: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
use_voice_router = (
|
||||
channel in voice_export_actions.VOICE_CHANNELS
|
||||
or bool(session_id)
|
||||
or voice_export_actions.wants_export_search(message)
|
||||
or webbuilder_agent.wants_website(message)
|
||||
)
|
||||
if use_voice_router:
|
||||
routed = await voice_export_actions.handle_voice_command(
|
||||
message,
|
||||
session_id=session_id,
|
||||
confirm_action_id=confirm_action_id,
|
||||
channel=channel,
|
||||
)
|
||||
if routed is not None:
|
||||
await _log_event(
|
||||
"herman",
|
||||
"voice_command" if channel == "voice" else "browser_command",
|
||||
(routed.get("reply") or "")[:120],
|
||||
message[:2000],
|
||||
{
|
||||
"pending_action": routed.get("pending_action"),
|
||||
"ui_actions": routed.get("ui_actions"),
|
||||
"channel": channel,
|
||||
},
|
||||
channel=channel,
|
||||
)
|
||||
return routed
|
||||
|
||||
if channel == "telegram" and _wants_briefing(message):
|
||||
from app.services.briefing import build_live_digest_text, collect_briefing_data
|
||||
|
||||
data = collect_briefing_data()
|
||||
reply = build_live_digest_text(data)
|
||||
await _log_event(
|
||||
"herman",
|
||||
"briefing",
|
||||
"Live briefing (Telegram)",
|
||||
reply[:1500],
|
||||
{"channel": channel, "source": "live_digest"},
|
||||
channel=channel,
|
||||
)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman · Briefing",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["herman"],
|
||||
"routing_reason": "Live cockpit-samenvatting",
|
||||
"agent_steps": _agent_steps([], "Live digest uit briefing engine"),
|
||||
}
|
||||
|
||||
if webbuilder_agent.wants_website(message):
|
||||
try:
|
||||
outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
|
||||
project = outcome.get("project") or "website"
|
||||
preview = outcome.get("preview_url") or ""
|
||||
reply_lines = [
|
||||
f"Web Builder is gestart voor project **{project}**.",
|
||||
f"NAS: {outcome.get('nas_path') or '—'}",
|
||||
f"Preview (na afloop): {preview}",
|
||||
"Volg de live terminal: Agents → Terminals → Web Builder.",
|
||||
]
|
||||
reply = "\n".join(reply_lines)
|
||||
await _log_event(
|
||||
"herman",
|
||||
"website_delegation",
|
||||
f"Herman → Web Builder: {project}",
|
||||
message[:2000],
|
||||
{
|
||||
"delegated": ["webbuilder"],
|
||||
"delegated_agents": ["webbuilder"],
|
||||
"project": project,
|
||||
"preview_url": preview,
|
||||
"channel": channel,
|
||||
},
|
||||
channel=channel,
|
||||
)
|
||||
delegated = ["webbuilder"]
|
||||
return {
|
||||
"agent": "webbuilder",
|
||||
"agent_label": "Web Builder → Herman",
|
||||
"reply": reply,
|
||||
"delegated_agents": delegated,
|
||||
"routing_reason": "Website-opdracht — Web Builder gestart op Hermes/agy",
|
||||
"agent_steps": _agent_steps(delegated, "Website build gestart"),
|
||||
"webbuilder_project": project,
|
||||
"webbuilder_preview_url": preview,
|
||||
"ui_actions": [
|
||||
{
|
||||
"type": "open_webbuilder_build",
|
||||
"title": f"Website build — {project}",
|
||||
"project": project,
|
||||
"preview_url": preview,
|
||||
"nas_path": outcome.get("nas_path") or "",
|
||||
"agents_url": "/agents",
|
||||
}
|
||||
],
|
||||
}
|
||||
except Exception as exc:
|
||||
await _log_event(
|
||||
"webbuilder",
|
||||
"website_build_error",
|
||||
"Website start mislukt",
|
||||
str(exc)[:1500],
|
||||
{"message": message[:500]},
|
||||
)
|
||||
return {
|
||||
"agent": "webbuilder",
|
||||
"agent_label": "Web Builder",
|
||||
"reply": f"Web Builder kon niet starten: {exc}",
|
||||
"delegated_agents": ["webbuilder"],
|
||||
}
|
||||
|
||||
if packaging_agent.wants_packaging(message):
|
||||
try:
|
||||
outcome = await packaging_agent.generate_from_message(message)
|
||||
@@ -112,7 +331,10 @@ async def chat(message: str) -> dict[str, Any]:
|
||||
"pdf_url": outcome.get("pdf_url"),
|
||||
"nas": outcome.get("nas"),
|
||||
"for_herman": True,
|
||||
"delegated": ["packaging"],
|
||||
"channel": channel,
|
||||
},
|
||||
channel=channel,
|
||||
)
|
||||
await _log_event(
|
||||
"herman",
|
||||
@@ -123,14 +345,19 @@ async def chat(message: str) -> dict[str, Any]:
|
||||
"source_agent": "packaging",
|
||||
"packaging_id": outcome.get("packaging_id"),
|
||||
"project_id": outcome.get("cockpit_project_id"),
|
||||
"delegated": ["packaging"],
|
||||
"channel": channel,
|
||||
},
|
||||
channel=channel,
|
||||
)
|
||||
delegated = ["packaging"]
|
||||
return {
|
||||
"agent": "packaging",
|
||||
"agent_label": "Packaging → Herman",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["packaging", "herman"],
|
||||
"delegated_agents": delegated,
|
||||
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
|
||||
"agent_steps": _agent_steps(delegated, "Packaging design gegenereerd"),
|
||||
"packaging_id": outcome.get("packaging_id"),
|
||||
"packaging_studio_url": outcome.get("studio_url"),
|
||||
"packaging_pdf_url": outcome.get("pdf_url"),
|
||||
@@ -165,13 +392,16 @@ async def chat(message: str) -> dict[str, Any]:
|
||||
img_type = data.get("type", "output")
|
||||
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
|
||||
reply = f"Afbeelding gegenereerd voor: {prompt}"
|
||||
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
|
||||
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename, "channel": channel}, channel=channel)
|
||||
delegated = ["design"]
|
||||
return {
|
||||
"agent": "design",
|
||||
"agent_label": "Design",
|
||||
"reply": reply,
|
||||
"image_url": proxy,
|
||||
"prompt": prompt,
|
||||
"delegated_agents": delegated,
|
||||
"agent_steps": _agent_steps(delegated, "Afbeelding gegenereerd"),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
@@ -180,30 +410,66 @@ async def chat(message: str) -> dict[str, Any]:
|
||||
"reply": f"Kon geen afbeelding genereren: {exc}",
|
||||
}
|
||||
|
||||
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
|
||||
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
|
||||
if backend in ("hermes", "router", "openrouter", "llm"):
|
||||
try:
|
||||
return await _chat_via_llm_router(message, channel, username=username, role=role)
|
||||
except Exception as exc:
|
||||
hint = (
|
||||
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
|
||||
if backend == "hermes"
|
||||
else "Vul OpenRouter key in via Instellingen → AI / LLM."
|
||||
)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": f"LLM provider niet beschikbaar: {exc}. {hint}",
|
||||
}
|
||||
|
||||
# orchestrator | auto — try Herman orchestrator first
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=620.0) as client:
|
||||
r = await client.post(
|
||||
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
|
||||
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
|
||||
json={"message": message, "agent": "default", "use_crm": True, "use_rag": True, "channel": channel},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
|
||||
reason = data.get("routing_reason", "") or ""
|
||||
await _log_event(
|
||||
"herman",
|
||||
"openswarm_delegation",
|
||||
"voice_delegation" if channel == "voice" else ("telegram_delegation" if channel == "telegram" else "openswarm_delegation"),
|
||||
f"Herman → {', '.join(delegated)}",
|
||||
message[:2000],
|
||||
{"delegated": delegated, "reason": data.get("routing_reason", "")},
|
||||
{"delegated": delegated, "delegated_agents": delegated, "reason": reason, "channel": channel},
|
||||
channel=channel,
|
||||
)
|
||||
return {
|
||||
"agent": data.get("agent", "herman"),
|
||||
"agent_label": data.get("agent_label", "Herman"),
|
||||
"reply": data.get("reply", ""),
|
||||
"delegated_agents": delegated,
|
||||
"routing_reason": data.get("routing_reason", ""),
|
||||
"routing_reason": reason,
|
||||
"agent_steps": _agent_steps(delegated, reason),
|
||||
"rag_sources": data.get("rag_sources") or [],
|
||||
"crm_loaded": data.get("crm_loaded", False),
|
||||
}
|
||||
except Exception as exc:
|
||||
if backend == "auto":
|
||||
try:
|
||||
return await _chat_via_llm_router(message, channel, username=username, role=role)
|
||||
except Exception as llm_exc:
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": (
|
||||
f"Orchestrator offline ({exc}). "
|
||||
f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
|
||||
"Check Hermes (:8642) of Instellingen → AI / LLM."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Koppel cockpit Herman-chat aan Hermes/Telegram memory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
log = logging.getLogger("cockpit.hermes_memory")
|
||||
|
||||
_USERNAME_MAP = {
|
||||
"aissa": "ceo",
|
||||
"aïssa": "ceo",
|
||||
"aissa zarioh": "ceo",
|
||||
"mo": "cto",
|
||||
"mohamed": "cto",
|
||||
}
|
||||
|
||||
|
||||
def resolve_hermes_user(
|
||||
*,
|
||||
username: str | None = None,
|
||||
role: str | None = None,
|
||||
channel: str | None = None,
|
||||
) -> str:
|
||||
"""OpenAI-compatible `user` field = Telegram chat_id for Hermes memory."""
|
||||
uname = (username or "").strip().lower()
|
||||
r = (role or "").strip().lower()
|
||||
mapped = _USERNAME_MAP.get(uname)
|
||||
if mapped == "ceo" or r in ("ceo", "admin") or uname in ("aissa", "aïssa"):
|
||||
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
|
||||
if mapped == "cto" or r == "cto" or uname in ("mo", "mohamed"):
|
||||
return str(settings.HERMES_CTO_CHAT_ID or "789036463")
|
||||
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
|
||||
|
||||
|
||||
async def fetch_recent_feed(chat_id: str | int, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
limit = limit or int(getattr(settings, "HERMES_MEMORY_LIMIT", 24) or 24)
|
||||
base = (settings.HERMES_FEED_URL or "").rstrip("/")
|
||||
if not base:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
r = await client.get(f"{base}/feed", params={"chat_id": int(chat_id), "limit": limit})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
items = (r.json() or {}).get("items") or []
|
||||
return list(reversed(items))
|
||||
except Exception as exc:
|
||||
log.warning("hermes feed fetch failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def feed_to_messages(items: list[dict[str, Any]], *, max_chars: int = 12000) -> list[dict[str, str]]:
|
||||
out: list[dict[str, str]] = []
|
||||
total = 0
|
||||
for it in items:
|
||||
text = (it.get("content_text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
role_raw = (it.get("role") or "").lower()
|
||||
role = "assistant" if role_raw in ("assistant", "hermes", "herman") else "user"
|
||||
if role == "assistant" and (it.get("direction") or "") == "in":
|
||||
role = "user"
|
||||
entry = {"role": role, "content": text[:2000]}
|
||||
total += len(entry["content"])
|
||||
if total > max_chars:
|
||||
break
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def continuity_system(chat_id: str, display_name: str | None = None) -> str:
|
||||
name = display_name or ("Aïssa" if str(chat_id) == str(settings.HERMES_CEO_CHAT_ID) else "de gebruiker")
|
||||
return (
|
||||
"Je bent Herman / Hermes, dezelfde AI Co-CEO als in Telegram. "
|
||||
f"Deze cockpit-chat is gekoppeld aan Telegram-sessie chat_id={chat_id} ({name}). "
|
||||
"Gebruik je bestaande Hermes-memory en recente Telegram-geschiedenis. "
|
||||
"Doe alsof dit dezelfde doorlopende conversatie is — geen nieuw persoon, geen amnesie. "
|
||||
"Antwoord in het Nederlands, bondig en actiegericht."
|
||||
)
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Unified LLM router — Ollama, DeepSeek, Gemini, Groq, OpenRouter, custom OpenAI-compatible."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import ollama
|
||||
|
||||
# Preset catalog for Settings UI (signup links + default models)
|
||||
LLM_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"ollama": {
|
||||
"label": "Ollama (lokaal)",
|
||||
"api_base_url": "",
|
||||
"models": ["qwen3:8b", "gemma3:12b", "llama3.2", "mistral"],
|
||||
"needs_key": False,
|
||||
"hint": "Geen API key — draait op je Ollama server.",
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"api_base_url": "https://api.deepseek.com/v1",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"needs_key": True,
|
||||
"signup_url": "https://platform.deepseek.com/",
|
||||
"hint": "Goedkoop · sterk voor code en analyse.",
|
||||
},
|
||||
"gemini": {
|
||||
"label": "Google Gemini",
|
||||
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"models": ["gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"],
|
||||
"needs_key": True,
|
||||
"signup_url": "https://aistudio.google.com/apikey",
|
||||
"hint": "Gratis tier via Google AI Studio.",
|
||||
},
|
||||
"groq": {
|
||||
"label": "Groq (snel · gratis tier)",
|
||||
"api_base_url": "https://api.groq.com/openai/v1",
|
||||
"models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
|
||||
"needs_key": True,
|
||||
"signup_url": "https://console.groq.com/",
|
||||
"hint": "Zeer snelle inference · gratis limiet.",
|
||||
},
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1",
|
||||
"models": [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"deepseek/deepseek-v4-flash:free",
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek/deepseek-r1",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"meta-llama/llama-3.3-70b-instruct:free",
|
||||
],
|
||||
"default_model": "deepseek/deepseek-v4-flash",
|
||||
"needs_key": True,
|
||||
"signup_url": "https://openrouter.ai/keys",
|
||||
"hint": "Eén API key · DeepSeek V4 Flash via openrouter.ai. Vul key in Instellingen → AI.",
|
||||
},
|
||||
"mistral": {
|
||||
"label": "Mistral AI",
|
||||
"api_base_url": "https://api.mistral.ai/v1",
|
||||
"models": ["mistral-small-latest", "open-mistral-nemo"],
|
||||
"needs_key": True,
|
||||
"signup_url": "https://console.mistral.ai/",
|
||||
"hint": "EU-hosted · gratis proef tier.",
|
||||
},
|
||||
"hermes": {
|
||||
"label": "Hermes Agent (Herman)",
|
||||
"api_base_url": "http://10.4.7.10:8642/v1",
|
||||
"models": ["hermes-agent"],
|
||||
"default_model": "hermes-agent",
|
||||
"needs_key": True,
|
||||
"hint": "OpenAI-compatible API van Hermes Agent LXC — OpenCode eronder.",
|
||||
},
|
||||
"custom_openai": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
"api_base_url": "",
|
||||
"models": [],
|
||||
"needs_key": True,
|
||||
"hint": "Elke API die /v1/chat/completions ondersteunt.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
OPENROUTER_PLACEHOLDER_LABEL = "OpenRouter · DeepSeek V4 Flash"
|
||||
|
||||
|
||||
def ensure_openrouter_placeholder() -> None:
|
||||
"""Seed inactive OpenRouter row so user can fill API key later in Settings."""
|
||||
row = fetch_one(
|
||||
"SELECT id FROM llm_providers WHERE provider_type = 'openrouter' LIMIT 1"
|
||||
)
|
||||
if row:
|
||||
return
|
||||
execute(
|
||||
"""INSERT INTO llm_providers (
|
||||
label, provider_type, api_base_url, api_key, model,
|
||||
is_active, is_default, extra_config, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())""",
|
||||
(
|
||||
OPENROUTER_PLACEHOLDER_LABEL,
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
False,
|
||||
False,
|
||||
json.dumps({"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _env_openrouter_provider() -> dict[str, Any] | None:
|
||||
from app.config import settings as app_settings
|
||||
|
||||
key = (app_settings.OPENROUTER_API_KEY or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
return {
|
||||
"id": -1,
|
||||
"label": "OpenRouter (environment)",
|
||||
"provider_type": "openrouter",
|
||||
"api_base_url": (app_settings.OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/"),
|
||||
"api_key": key,
|
||||
"model": app_settings.OPENROUTER_MODEL or "deepseek/deepseek-v4-flash",
|
||||
"extra_config": {"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _env_hermes_provider() -> dict[str, Any] | None:
|
||||
from app.config import settings as app_settings
|
||||
|
||||
key = (app_settings.HERMES_API_KEY or "").strip()
|
||||
base = (app_settings.HERMES_API_BASE or "").strip()
|
||||
if not key or not base:
|
||||
return None
|
||||
return {
|
||||
"id": -2,
|
||||
"label": "Hermes Agent (Herman)",
|
||||
"provider_type": "hermes",
|
||||
"api_base_url": base.rstrip("/"),
|
||||
"api_key": key,
|
||||
"model": app_settings.HERMES_API_MODEL or "hermes-agent",
|
||||
"extra_config": {"temperature": 0.35, "max_tokens": 4096},
|
||||
}
|
||||
|
||||
|
||||
def list_presets() -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for key, meta in LLM_PRESETS.items():
|
||||
row = dict(meta)
|
||||
row["id"] = key
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _mask_provider(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
if isinstance(out.get("extra_config"), str):
|
||||
try:
|
||||
out["extra_config"] = json.loads(out["extra_config"])
|
||||
except Exception:
|
||||
out["extra_config"] = {}
|
||||
out["api_key_set"] = bool(row.get("api_key"))
|
||||
out.pop("api_key", None)
|
||||
preset = LLM_PRESETS.get(out.get("provider_type") or "", {})
|
||||
out["preset_label"] = preset.get("label", out.get("provider_type"))
|
||||
out["needs_key"] = preset.get("needs_key", True)
|
||||
return out
|
||||
|
||||
|
||||
def list_providers() -> list[dict[str, Any]]:
|
||||
rows = fetch_all("SELECT * FROM llm_providers ORDER BY is_default DESC, is_active DESC, id ASC")
|
||||
return [_mask_provider(r) for r in rows if r]
|
||||
|
||||
|
||||
def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
|
||||
if provider_id:
|
||||
return fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
row = fetch_one(
|
||||
"SELECT * FROM llm_providers WHERE is_default = TRUE ORDER BY id LIMIT 1"
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
row = fetch_one(
|
||||
"SELECT * FROM llm_providers WHERE is_active = TRUE ORDER BY id LIMIT 1"
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
return fetch_one("SELECT * FROM llm_providers ORDER BY id LIMIT 1")
|
||||
|
||||
|
||||
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
|
||||
from app.config import settings as app_settings
|
||||
|
||||
backend = (app_settings.HERMAN_LLM_BACKEND or "auto").lower()
|
||||
hermes = _env_hermes_provider()
|
||||
if hermes and backend in ("hermes", "auto"):
|
||||
# Prefer Hermes when configured (Ollama is often offline)
|
||||
if backend == "hermes" or provider_id is None:
|
||||
row = get_provider(provider_id) if provider_id else None
|
||||
# Explicit provider_id still wins when set
|
||||
if provider_id and row:
|
||||
pass
|
||||
else:
|
||||
return hermes
|
||||
|
||||
row = get_provider(provider_id)
|
||||
if not row:
|
||||
if hermes:
|
||||
return hermes
|
||||
env_or = _env_openrouter_provider()
|
||||
if env_or:
|
||||
return env_or
|
||||
return {
|
||||
"id": 0,
|
||||
"label": "Ollama lokaal",
|
||||
"provider_type": "ollama",
|
||||
"api_base_url": settings.OLLAMA_URL,
|
||||
"api_key": "",
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"extra_config": {},
|
||||
}
|
||||
env_or = _env_openrouter_provider()
|
||||
ptype = (row.get("provider_type") or "").lower()
|
||||
if hermes and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
|
||||
return hermes
|
||||
if env_or and ptype == "openrouter" and not (row.get("api_key") or "").strip():
|
||||
merged = dict(row)
|
||||
merged["api_key"] = env_or["api_key"]
|
||||
if not (merged.get("model") or "").strip():
|
||||
merged["model"] = env_or["model"]
|
||||
return merged
|
||||
if env_or and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
|
||||
if backend in ("router", "openrouter"):
|
||||
return env_or
|
||||
return row
|
||||
|
||||
|
||||
async def chat_messages(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
provider_id: int | None = None,
|
||||
model: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
user: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Returns (reply_text, meta dict with provider info)."""
|
||||
prov = resolve_provider(provider_id)
|
||||
ptype = (prov.get("provider_type") or "ollama").lower()
|
||||
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
|
||||
ollama_timeout = min(timeout, 85.0) if ptype == "ollama" else timeout
|
||||
|
||||
if ptype == "ollama":
|
||||
try:
|
||||
reply = await ollama.chat_messages(messages, timeout=ollama_timeout, model=use_model)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Ollama timeout/ offline ({exc}). "
|
||||
"Voeg DeepSeek of Gemini toe via Instellingen → AI / LLM voor snelle cloud-chat."
|
||||
) from exc
|
||||
return reply, {
|
||||
"provider_id": prov.get("id"),
|
||||
"provider_type": "ollama",
|
||||
"provider_label": prov.get("label") or "Ollama",
|
||||
"model": use_model,
|
||||
}
|
||||
|
||||
api_key = (prov.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"Geen API key voor {prov.get('label') or ptype} — voeg key toe in Instellingen → AI / LLM"
|
||||
)
|
||||
|
||||
base = (prov.get("api_base_url") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
preset = LLM_PRESETS.get(ptype, {})
|
||||
base = (preset.get("api_base_url") or "").rstrip("/")
|
||||
if not base:
|
||||
raise RuntimeError(f"Geen API URL voor provider {prov.get('label')}")
|
||||
|
||||
extra = prov.get("extra_config") or {}
|
||||
if isinstance(extra, str):
|
||||
try:
|
||||
extra = json.loads(extra)
|
||||
except Exception:
|
||||
extra = {}
|
||||
|
||||
url = f"{base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if ptype == "openrouter":
|
||||
headers["HTTP-Referer"] = extra.get("referer", "https://foodlinkk.local")
|
||||
headers["X-Title"] = extra.get("title", "Foodlinkk Command Center")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": use_model,
|
||||
"messages": messages,
|
||||
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)),
|
||||
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 2048)),
|
||||
}
|
||||
# Hermes: `user` = Telegram chat_id → koppelt API-chat aan bestaande memory/sessie
|
||||
if user:
|
||||
payload["user"] = str(user)
|
||||
elif ptype == "hermes":
|
||||
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
detail = resp.text[:500]
|
||||
try:
|
||||
detail = resp.json().get("error", {}).get("message", detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"{prov.get('label')}: {detail}")
|
||||
data = resp.json()
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise RuntimeError(f"{prov.get('label')}: leeg antwoord")
|
||||
content = (choices[0].get("message") or {}).get("content") or ""
|
||||
return content.strip(), {
|
||||
"provider_id": prov.get("id"),
|
||||
"provider_type": ptype,
|
||||
"provider_label": prov.get("label"),
|
||||
"model": use_model,
|
||||
"hermes_user": payload.get("user"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
async def stream_chat_messages(
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
provider_id: int | None = None,
|
||||
model: str | None = None,
|
||||
timeout: float = 90.0,
|
||||
user: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
):
|
||||
"""Yield text deltas from Hermes/OpenAI-compatible streaming chat."""
|
||||
prov = resolve_provider(provider_id)
|
||||
ptype = (prov.get("provider_type") or "ollama").lower()
|
||||
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
|
||||
if ptype != "hermes":
|
||||
# non-stream fallback: one chunk
|
||||
reply, _meta = await chat_messages(
|
||||
messages,
|
||||
provider_id=provider_id,
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
user=user,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
if reply:
|
||||
yield reply
|
||||
return
|
||||
|
||||
api_key = (prov.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError("Geen Hermes API key")
|
||||
base = (prov.get("api_base_url") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
raise RuntimeError("Geen Hermes API URL")
|
||||
extra = prov.get("extra_config") or {}
|
||||
if isinstance(extra, str):
|
||||
try:
|
||||
extra = json.loads(extra)
|
||||
except Exception:
|
||||
extra = {}
|
||||
url = f"{base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": use_model,
|
||||
"messages": messages,
|
||||
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.35)),
|
||||
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 512)),
|
||||
"stream": True,
|
||||
}
|
||||
if user:
|
||||
payload["user"] = str(user)
|
||||
else:
|
||||
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=payload) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body = (await resp.aread()).decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Hermes stream fout ({resp.status_code}): {body}")
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if not data or data == "[DONE]":
|
||||
if data == "[DONE]":
|
||||
break
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
choices = obj.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = (choices[0].get("delta") or {}).get("content")
|
||||
if delta:
|
||||
yield delta
|
||||
|
||||
|
||||
async def generate(
|
||||
prompt: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
provider_id: int | None = None,
|
||||
model: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> str:
|
||||
messages: list[dict[str, str]] = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
reply, _meta = await chat_messages(
|
||||
messages, provider_id=provider_id, model=model, timeout=timeout
|
||||
)
|
||||
return reply
|
||||
|
||||
|
||||
async def test_provider(provider_id: int) -> tuple[bool, str]:
|
||||
prov = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if not prov:
|
||||
return False, "Provider niet gevonden"
|
||||
try:
|
||||
reply, meta = await chat_messages(
|
||||
[{"role": "user", "content": "Antwoord met exact één woord: OK"}],
|
||||
provider_id=provider_id,
|
||||
timeout=60.0,
|
||||
)
|
||||
msg = f"{meta.get('provider_label')} · {meta.get('model')} — {reply[:80]}"
|
||||
execute(
|
||||
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
|
||||
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
|
||||
("ok", msg, provider_id),
|
||||
)
|
||||
return True, msg
|
||||
except Exception as exc:
|
||||
execute(
|
||||
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
|
||||
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
|
||||
("error", str(exc)[:500], provider_id),
|
||||
)
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def set_default(provider_id: int) -> None:
|
||||
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||||
execute(
|
||||
"UPDATE llm_providers SET is_default = TRUE, is_active = TRUE, updated_at = NOW() WHERE id = %s",
|
||||
(provider_id,),
|
||||
)
|
||||
+517
-50
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import execute, fetch_one, get_connection
|
||||
from app.db import execute, fetch_all, fetch_one, get_connection
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
|
||||
|
||||
@@ -45,6 +46,59 @@ def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_parse_info(
|
||||
html: str,
|
||||
text: str,
|
||||
title: str,
|
||||
final_url: str,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
soup = BeautifulSoup(html or "", "html.parser")
|
||||
headings: list[str] = []
|
||||
for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
|
||||
t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
|
||||
if t:
|
||||
headings.append(t[:140])
|
||||
|
||||
links_sample: list[dict[str, str]] = []
|
||||
seen_hrefs: set[str] = set()
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = (a.get("href") or "").strip()
|
||||
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
|
||||
continue
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(base_url or final_url, href)
|
||||
if href in seen_hrefs:
|
||||
continue
|
||||
seen_hrefs.add(href)
|
||||
label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
|
||||
links_sample.append({"href": href, "label": label or href})
|
||||
if len(links_sample) >= 10:
|
||||
break
|
||||
|
||||
words = len(text.split()) if text else 0
|
||||
excerpt = ""
|
||||
if text:
|
||||
excerpt = text[:320] + ("…" if len(text) > 320 else "")
|
||||
|
||||
meta_desc = ""
|
||||
md = soup.find("meta", attrs={"name": "description"})
|
||||
if md and md.get("content"):
|
||||
meta_desc = str(md["content"]).strip()[:240]
|
||||
|
||||
return {
|
||||
"title": title or "(geen titel)",
|
||||
"final_url": final_url,
|
||||
"word_count": words,
|
||||
"char_count": len(text or ""),
|
||||
"excerpt": excerpt,
|
||||
"meta_description": meta_desc,
|
||||
"headings": headings,
|
||||
"links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
|
||||
"links_sample": links_sample,
|
||||
}
|
||||
|
||||
|
||||
def get_page_hash(url: str) -> str | None:
|
||||
fetched = _fetch_page(url)
|
||||
if not fetched:
|
||||
@@ -53,20 +107,31 @@ def get_page_hash(url: str) -> str | None:
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
|
||||
import json
|
||||
def _save_snapshot(
|
||||
site_id: int,
|
||||
url: str,
|
||||
final_url: str,
|
||||
title: str,
|
||||
text: str,
|
||||
html: str,
|
||||
parse_info: dict[str, Any] | None = None,
|
||||
) -> int | None:
|
||||
parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
|
||||
metadata = {"source": "monitor", "parse": parse_info}
|
||||
links_json = json.dumps(parse_info.get("links_sample") or [])
|
||||
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
|
||||
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
|
||||
site_id=EXCLUDED.site_id, crawled_at=NOW()
|
||||
site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
|
||||
links=EXCLUDED.links, crawled_at=NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@@ -76,7 +141,8 @@ def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str
|
||||
text[:50000],
|
||||
html[:100000],
|
||||
site_id,
|
||||
json.dumps({"source": "monitor"}),
|
||||
json.dumps(metadata),
|
||||
links_json,
|
||||
),
|
||||
)
|
||||
page_id = cur.fetchone()[0]
|
||||
@@ -125,7 +191,8 @@ def add_site(url: str, name: str) -> dict:
|
||||
)
|
||||
site_id = cur.fetchone()[0]
|
||||
if fetched:
|
||||
_save_snapshot(site_id, url, final_url, title, text, html)
|
||||
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
||||
_save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
||||
row = fetch_one(
|
||||
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
|
||||
(site_id,),
|
||||
@@ -142,61 +209,461 @@ def remove_site(site_id: int, soft: bool = True) -> None:
|
||||
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
|
||||
|
||||
|
||||
def trigger_crawl(site_id: int | None = None) -> dict:
|
||||
try:
|
||||
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
|
||||
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
|
||||
return {"ok": True, "method": "worker"}
|
||||
except Exception:
|
||||
pass
|
||||
def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
|
||||
site_id = int(site["id"])
|
||||
url = site["url"]
|
||||
name = site.get("name") or url
|
||||
result: dict[str, Any] = {
|
||||
"site_id": site_id,
|
||||
"url": url,
|
||||
"name": name,
|
||||
"status": "ERROR",
|
||||
"changed": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
from app.db import fetch_all
|
||||
|
||||
if site_id:
|
||||
row = fetch_one(
|
||||
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
|
||||
(site_id,),
|
||||
)
|
||||
sites = [row] if row else []
|
||||
else:
|
||||
sites = fetch_all(
|
||||
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
|
||||
)
|
||||
|
||||
changed = 0
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for site in sites:
|
||||
fetched = _fetch_page(site["url"])
|
||||
fetched = _fetch_page(url)
|
||||
if not fetched:
|
||||
cur.execute(
|
||||
msg = f"Kan {url} niet bereiken"
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
|
||||
(site_id, "ERROR", msg),
|
||||
)
|
||||
continue
|
||||
result["error"] = msg
|
||||
return result
|
||||
|
||||
final_url, title, text, html = fetched
|
||||
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
||||
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
old_hash = site.get("last_hash")
|
||||
if old_hash and old_hash != new_hash:
|
||||
cur.execute(
|
||||
changed = bool(old_hash and old_hash != new_hash)
|
||||
|
||||
if changed:
|
||||
execute(
|
||||
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
|
||||
(site["id"], old_hash, new_hash),
|
||||
(site_id, old_hash, new_hash),
|
||||
)
|
||||
cur.execute(
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
|
||||
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
|
||||
)
|
||||
changed += 1
|
||||
result["status"] = "CHANGE"
|
||||
else:
|
||||
cur.execute(
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "OK", f"Crawl OK — {title}"),
|
||||
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
|
||||
)
|
||||
cur.execute(
|
||||
result["status"] = "OK"
|
||||
|
||||
execute(
|
||||
"""
|
||||
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
|
||||
""",
|
||||
(new_hash, title, site["id"]),
|
||||
(new_hash, title, site_id),
|
||||
)
|
||||
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
|
||||
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
|
||||
snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
||||
|
||||
result.update(parse_info)
|
||||
result["changed"] = changed
|
||||
result["snapshot_id"] = snapshot_id
|
||||
return result
|
||||
|
||||
|
||||
def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
|
||||
if site_id:
|
||||
row = fetch_one(
|
||||
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
|
||||
(site_id,),
|
||||
)
|
||||
sites = [dict(row)] if row else []
|
||||
else:
|
||||
sites = [
|
||||
dict(r)
|
||||
for r in fetch_all(
|
||||
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
|
||||
)
|
||||
]
|
||||
|
||||
if not sites:
|
||||
return {
|
||||
"ok": True,
|
||||
"method": "inline",
|
||||
"sites": 0,
|
||||
"changed": 0,
|
||||
"errors": 0,
|
||||
"results": [],
|
||||
"message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
|
||||
}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
changed = 0
|
||||
errors = 0
|
||||
for site in sites:
|
||||
row = _crawl_one_site(site)
|
||||
results.append(row)
|
||||
if row.get("status") == "ERROR":
|
||||
errors += 1
|
||||
if row.get("changed"):
|
||||
changed += 1
|
||||
|
||||
return {
|
||||
"ok": errors < len(sites),
|
||||
"method": "inline",
|
||||
"sites": len(sites),
|
||||
"changed": changed,
|
||||
"errors": errors,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
limit = max(1, min(limit, 50))
|
||||
if site_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id = %s
|
||||
ORDER BY cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(site_id, limit),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
ORDER BY cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
meta = item.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
parse = (meta or {}).get("parse") or {}
|
||||
content = item.get("content") or ""
|
||||
if not parse.get("excerpt") and content:
|
||||
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
||||
if not parse.get("word_count") and content:
|
||||
parse["word_count"] = len(str(content).split())
|
||||
links = item.get("links") or []
|
||||
if isinstance(links, str):
|
||||
try:
|
||||
links = json.loads(links)
|
||||
except Exception:
|
||||
links = []
|
||||
if not parse.get("links_sample") and links:
|
||||
parse["links_sample"] = links
|
||||
crawled = item.get("crawled_at")
|
||||
if crawled is not None and hasattr(crawled, "isoformat"):
|
||||
item["crawled_at"] = crawled.isoformat()
|
||||
out.append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"site_name": item.get("site_name"),
|
||||
"url": item.get("url"),
|
||||
"final_url": item.get("final_url"),
|
||||
"title": item.get("title") or parse.get("title"),
|
||||
"crawled_at": item.get("crawled_at"),
|
||||
"word_count": parse.get("word_count", 0),
|
||||
"excerpt": parse.get("excerpt", ""),
|
||||
"meta_description": parse.get("meta_description", ""),
|
||||
"headings": parse.get("headings") or [],
|
||||
"links_count": parse.get("links_count", len(links)),
|
||||
"links_sample": parse.get("links_sample") or links[:10],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
NL_STOPWORDS = frozenset(
|
||||
"""
|
||||
de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
|
||||
kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
|
||||
heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
|
||||
for on at by from with about into through during before after above below between under again
|
||||
further then once here there when where why how all each few more most other some such no nor
|
||||
not only own same so than too very just don should now naar website home pagina menu contact
|
||||
service cookie cookies privacy login inloggen registreren meer lees read click klik
|
||||
""".split()
|
||||
)
|
||||
|
||||
|
||||
def _tokens(text: str, min_len: int = 4) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
|
||||
return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
|
||||
|
||||
|
||||
def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
meta = item.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
parse = (meta or {}).get("parse") or {}
|
||||
content = str(item.get("content") or "")
|
||||
if not parse.get("excerpt") and content:
|
||||
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
||||
if not parse.get("word_count") and content:
|
||||
parse["word_count"] = len(content.split())
|
||||
links = item.get("links") or []
|
||||
if isinstance(links, str):
|
||||
try:
|
||||
links = json.loads(links)
|
||||
except Exception:
|
||||
links = []
|
||||
if not parse.get("links_sample") and links:
|
||||
parse["links_sample"] = links
|
||||
crawled = item.get("crawled_at")
|
||||
if crawled is not None and hasattr(crawled, "isoformat"):
|
||||
crawled = crawled.isoformat()
|
||||
content_read = content[:content_limit]
|
||||
if len(content) > content_limit:
|
||||
content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"site_name": item.get("site_name"),
|
||||
"url": item.get("url"),
|
||||
"final_url": item.get("final_url"),
|
||||
"title": item.get("title") or parse.get("title"),
|
||||
"crawled_at": crawled,
|
||||
"word_count": parse.get("word_count", 0),
|
||||
"char_count": parse.get("char_count", len(content)),
|
||||
"excerpt": parse.get("excerpt", ""),
|
||||
"meta_description": parse.get("meta_description", ""),
|
||||
"headings": parse.get("headings") or [],
|
||||
"links_count": parse.get("links_count", len(links)),
|
||||
"links_sample": parse.get("links_sample") or links[:15],
|
||||
"content_read": content_read,
|
||||
"content_length": len(content),
|
||||
"has_full_content": len(content) > 0,
|
||||
}
|
||||
|
||||
|
||||
def get_parse_page(page_id: int) -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.id = %s
|
||||
""",
|
||||
(page_id,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
page = _normalize_page_row(dict(row), content_limit=50000)
|
||||
page["content_full"] = str(dict(row).get("content") or "")
|
||||
return page
|
||||
|
||||
|
||||
def build_parse_intelligence(
|
||||
site_id: int | None = None,
|
||||
query: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate parsed pages for analysis — hype terms, trends, readable content."""
|
||||
limit = max(1, min(limit, 100))
|
||||
if site_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT DISTINCT ON (cp.site_id)
|
||||
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id = %s
|
||||
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT DISTINCT ON (cp.site_id)
|
||||
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id IS NOT NULL
|
||||
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
pages = [_normalize_page_row(dict(r)) for r in rows]
|
||||
q = (query or "").strip().lower()
|
||||
if q:
|
||||
pages = [
|
||||
p
|
||||
for p in pages
|
||||
if q in (p.get("title") or "").lower()
|
||||
or q in (p.get("content_read") or "").lower()
|
||||
or q in (p.get("excerpt") or "").lower()
|
||||
or any(q in h.lower() for h in p.get("headings") or [])
|
||||
]
|
||||
|
||||
changed_site_ids: set[int] = set()
|
||||
recent_changes: list[dict[str, Any]] = []
|
||||
try:
|
||||
change_rows = fetch_all(
|
||||
"""
|
||||
SELECT pc.site_id, pc.changed_at, ms.name, ms.url
|
||||
FROM page_changes pc
|
||||
JOIN monitored_sites ms ON ms.id = pc.site_id
|
||||
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY pc.changed_at DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
)
|
||||
for cr in change_rows:
|
||||
sid = int(cr["site_id"])
|
||||
changed_site_ids.add(sid)
|
||||
ts = cr.get("changed_at")
|
||||
if ts is not None and hasattr(ts, "isoformat"):
|
||||
ts = ts.isoformat()
|
||||
recent_changes.append(
|
||||
{
|
||||
"site_id": sid,
|
||||
"site_name": cr.get("name"),
|
||||
"url": cr.get("url"),
|
||||
"changed_at": ts,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
term_scores: dict[str, dict[str, Any]] = {}
|
||||
heading_counts: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bump_term(term: str, site_name: str, weight: int = 1) -> None:
|
||||
if len(term) < 3:
|
||||
return
|
||||
bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
|
||||
bucket["score"] += weight
|
||||
if site_name:
|
||||
bucket["sites"].add(site_name)
|
||||
|
||||
for page in pages:
|
||||
site_name = page.get("site_name") or str(page.get("site_id") or "")
|
||||
for tok in _tokens(page.get("title") or "", min_len=3):
|
||||
bump_term(tok, site_name, 3)
|
||||
for h in page.get("headings") or []:
|
||||
hnorm = re.sub(r"\s+", " ", h.strip())[:80]
|
||||
if len(hnorm) < 3:
|
||||
continue
|
||||
hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
|
||||
hc["count"] += 1
|
||||
hc["sites"].add(site_name)
|
||||
for tok in _tokens(h, min_len=3):
|
||||
bump_term(tok, site_name, 4)
|
||||
for tok in _tokens(page.get("content_read") or ""):
|
||||
bump_term(tok, site_name, 1)
|
||||
for link in page.get("links_sample") or []:
|
||||
for tok in _tokens(link.get("label") or "", min_len=3):
|
||||
bump_term(tok, site_name, 2)
|
||||
|
||||
hype_terms: list[dict[str, Any]] = []
|
||||
for term, data in term_scores.items():
|
||||
if data["score"] < 4:
|
||||
continue
|
||||
sites_list = sorted(data["sites"])
|
||||
hype_terms.append(
|
||||
{
|
||||
"term": term,
|
||||
"score": data["score"],
|
||||
"site_count": len(sites_list),
|
||||
"sites": sites_list[:5],
|
||||
"cross_site": len(sites_list) >= 2,
|
||||
}
|
||||
)
|
||||
hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
|
||||
hype_terms = hype_terms[:40]
|
||||
|
||||
heading_trends = []
|
||||
for _key, data in heading_counts.items():
|
||||
if data["count"] < 1:
|
||||
continue
|
||||
heading_trends.append(
|
||||
{
|
||||
"label": data["label"],
|
||||
"count": data["count"],
|
||||
"sites": sorted(data["sites"])[:6],
|
||||
"cross_site": len(data["sites"]) >= 2,
|
||||
}
|
||||
)
|
||||
heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
|
||||
heading_trends = heading_trends[:25]
|
||||
|
||||
top_term_set = {t["term"] for t in hype_terms[:15]}
|
||||
food_signals = frozenset(
|
||||
"halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
|
||||
"groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
|
||||
"supermarkt retail assortiment prijs private label merk".split()
|
||||
)
|
||||
|
||||
for page in pages:
|
||||
signals: list[str] = []
|
||||
wc = int(page.get("word_count") or 0)
|
||||
sid = page.get("site_id")
|
||||
if wc >= 1500:
|
||||
signals.append("Rijke pagina — veel te analyseren")
|
||||
elif wc >= 400:
|
||||
signals.append("Normale pagina-dichtheid")
|
||||
if sid in changed_site_ids:
|
||||
signals.append("Recent gewijzigd — mogelijke hype/shift")
|
||||
|
||||
page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
|
||||
matched_hype = [t for t in top_term_set if t in page_terms]
|
||||
food_hits = [t for t in page_terms if t in food_signals]
|
||||
for t in matched_hype[:4]:
|
||||
signals.append(f"Trend-term: {t}")
|
||||
for t in food_hits[:3]:
|
||||
if f"Trend-term: {t}" not in signals:
|
||||
signals.append(f"Food-signaal: {t}")
|
||||
|
||||
if page.get("meta_description"):
|
||||
signals.append("SEO meta beschikbaar")
|
||||
|
||||
page["signals"] = signals[:8]
|
||||
page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
|
||||
page["recently_changed"] = sid in changed_site_ids
|
||||
|
||||
pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
|
||||
|
||||
total_words = sum(int(p.get("word_count") or 0) for p in pages)
|
||||
return {
|
||||
"summary": {
|
||||
"pages": len(pages),
|
||||
"total_words": total_words,
|
||||
"themes_detected": len(hype_terms),
|
||||
"headings_unique": len(heading_trends),
|
||||
"changes_7d": len(recent_changes),
|
||||
"query": q or None,
|
||||
},
|
||||
"hype_terms": hype_terms,
|
||||
"heading_trends": heading_trends,
|
||||
"recent_changes": recent_changes[:12],
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
@@ -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,180 @@
|
||||
"""Daily Herman War Room digest → Telegram (Aïssa + Mo)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import llm_router
|
||||
|
||||
log = logging.getLogger("cockpit.warroom_digest")
|
||||
|
||||
|
||||
def _recipients() -> list[tuple[str, str]]:
|
||||
"""(chat_id, display_name)"""
|
||||
return [
|
||||
(str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), "Aïssa"),
|
||||
(str(getattr(settings, "HERMES_CTO_CHAT_ID", None) or "789036463"), "Mo"),
|
||||
]
|
||||
|
||||
|
||||
def collect_warroom_day(*, hours: int = 24, limit: int = 120) -> list[dict[str, Any]]:
|
||||
hours = max(1, min(int(hours), 72))
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT m.id, m.sender_type, m.agent_key, m.username, m.content, m.created_at, r.kind, r.title
|
||||
FROM warroom_messages m
|
||||
JOIN warroom_rooms r ON r.id = m.room_id
|
||||
WHERE m.created_at >= NOW() - make_interval(hours => %s)
|
||||
AND r.kind = 'warroom'
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(hours, limit),
|
||||
)
|
||||
out = []
|
||||
for r in rows or []:
|
||||
item = dict(r)
|
||||
if item.get("created_at") and hasattr(item["created_at"], "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _transcript(messages: list[dict[str, Any]], *, max_chars: int = 6000) -> str:
|
||||
lines: list[str] = []
|
||||
for m in messages:
|
||||
st = m.get("sender_type")
|
||||
if st == "user":
|
||||
who = m.get("username") or "user"
|
||||
elif st == "agent":
|
||||
who = m.get("agent_key") or "agent"
|
||||
else:
|
||||
who = "system"
|
||||
content = (m.get("content") or "").strip().replace("\n", " ")
|
||||
if not content:
|
||||
continue
|
||||
lines.append(f"- {who}: {content[:280]}")
|
||||
text = "\n".join(lines)
|
||||
return text[-max_chars:]
|
||||
|
||||
|
||||
async def generate_digest_text(messages: list[dict[str, Any]]) -> str:
|
||||
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
if not messages:
|
||||
return (
|
||||
f"📋 War Room dagrapport {day}\n\n"
|
||||
"Geen activiteit in #war-room vandaag. Agents stonden klaar; geen overleg gelogd."
|
||||
)
|
||||
transcript = _transcript(messages)
|
||||
system = (
|
||||
"Je bent Herman, Co-CEO van Foodlinkk. Schrijf een beknopte Nederlandse "
|
||||
"Telegram-samenvatting van het War Room-overleg voor Aïssa (CEO) en Mo (CTO).\n"
|
||||
"Structuur:\n"
|
||||
f"1) Kop: 📋 War Room dagrapport {day}\n"
|
||||
"2) 3–6 bullets: wat agents bespraken (pipeline, deals, acties, blockers)\n"
|
||||
"3) Sectie 'Acties / open punten' met concrete next steps\n"
|
||||
"4) 1 zin afsluiting\n"
|
||||
"Max ~350 woorden. Geen @zelf-prefix. Geen 'agents staan uit'."
|
||||
)
|
||||
user = f"War Room transcript (nieuwste onderaan):\n{transcript}"
|
||||
reply, _meta = await llm_router.chat_messages(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
timeout=90.0,
|
||||
user=str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"),
|
||||
max_tokens=900,
|
||||
temperature=0.3,
|
||||
)
|
||||
text = (reply or "").strip()
|
||||
if not text.startswith("📋"):
|
||||
text = f"📋 War Room dagrapport {day}\n\n{text}"
|
||||
return text[:3500]
|
||||
|
||||
|
||||
async def _deliver_telegram(chat_id: str, name: str, digest: str) -> dict[str, Any]:
|
||||
"""Ask Hermes agent to deliver the digest into that Telegram chat."""
|
||||
system = (
|
||||
f"Je bent Herman. Bezorg het volgende War Room-dagrapport NU via Telegram "
|
||||
f"aan {name} (chat_id={chat_id}). Stuur de tekst zo letterlijk mogelijk. "
|
||||
"Bevestig kort of verzonden."
|
||||
)
|
||||
try:
|
||||
reply, meta = await llm_router.chat_messages(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": digest},
|
||||
],
|
||||
timeout=120.0,
|
||||
user=str(chat_id),
|
||||
max_tokens=200,
|
||||
temperature=0.1,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"chat_id": chat_id,
|
||||
"name": name,
|
||||
"confirm": (reply or "")[:240],
|
||||
"provider": (meta or {}).get("provider_type"),
|
||||
}
|
||||
except Exception as exc:
|
||||
log.exception("warroom digest deliver failed %s", chat_id)
|
||||
return {"ok": False, "chat_id": chat_id, "name": name, "error": str(exc)}
|
||||
|
||||
|
||||
def _persist(digest: str, message_count: int, deliveries: list[dict[str, Any]]) -> None:
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
"herman",
|
||||
"warroom_digest",
|
||||
"warroom_daily_digest",
|
||||
"War Room dagrapport verstuurd",
|
||||
digest[:4000],
|
||||
"completed",
|
||||
"telegram",
|
||||
__import__("json").dumps(
|
||||
{
|
||||
"message_count": message_count,
|
||||
"deliveries": deliveries,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("persist digest event failed")
|
||||
|
||||
try:
|
||||
from app.services import agent_chat
|
||||
|
||||
agent_chat.post_system_line(
|
||||
"📤 Dagrapport verstuurd naar Aïssa + Mo via Telegram.\n\n" + digest[:1500],
|
||||
agent_key="herman",
|
||||
metadata={"kind": "warroom_digest", "deliveries": deliveries},
|
||||
)
|
||||
except Exception:
|
||||
log.exception("post digest to warroom failed")
|
||||
|
||||
|
||||
async def run_daily_digest(*, hours: int = 24, deliver: bool = True) -> dict[str, Any]:
|
||||
messages = collect_warroom_day(hours=hours)
|
||||
digest = await generate_digest_text(messages)
|
||||
deliveries: list[dict[str, Any]] = []
|
||||
if deliver:
|
||||
for chat_id, name in _recipients():
|
||||
deliveries.append(await _deliver_telegram(chat_id, name, digest))
|
||||
_persist(digest, len(messages), deliveries)
|
||||
return {
|
||||
"ok": True,
|
||||
"message_count": len(messages),
|
||||
"digest": digest,
|
||||
"deliveries": deliveries,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -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,10 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:8643 {
|
||||
tls /certs/cert.pem /certs/key.pem
|
||||
encode gzip
|
||||
reverse_proxy cockpit:8600
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
|
||||
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
|
||||
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
|
||||
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
|
||||
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
|
||||
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
|
||||
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
|
||||
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
|
||||
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
|
||||
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
|
||||
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
|
||||
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
|
||||
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
|
||||
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
|
||||
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
|
||||
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
|
||||
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
|
||||
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
|
||||
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
|
||||
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
|
||||
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
|
||||
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
|
||||
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
|
||||
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
|
||||
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
|
||||
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
|
||||
c9ibkVARig==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD
|
||||
DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq
|
||||
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ
|
||||
MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP
|
||||
0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL
|
||||
45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f
|
||||
stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3
|
||||
By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg
|
||||
MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw
|
||||
hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs
|
||||
MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d
|
||||
KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN
|
||||
L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2
|
||||
kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC
|
||||
9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV
|
||||
KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x
|
||||
p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/
|
||||
BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV
|
||||
BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag
|
||||
3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg
|
||||
8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u
|
||||
k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64
|
||||
HJOEFqjE6LCTsSnqN2da2GGm5AY=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
prompt = no
|
||||
[req_distinguished_name]
|
||||
CN = 10.4.7.18
|
||||
O = Foodlinkk Cockpit
|
||||
[v3_req]
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = @alt_names
|
||||
[alt_names]
|
||||
IP.1 = 10.4.7.18
|
||||
IP.2 = 127.0.0.1
|
||||
DNS.1 = localhost
|
||||
DNS.2 = cockpit.foodlinkk.local
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.12
|
||||
psycopg2-binary==2.9.9
|
||||
httpx==0.27.2
|
||||
websockets==13.1
|
||||
beautifulsoup4==4.12.3
|
||||
fastapi==0.115.6
|
||||
httpx==0.27.2
|
||||
itsdangerous>=2.1.0
|
||||
jinja2==3.1.4
|
||||
openpyxl>=3.1.0
|
||||
psycopg2-binary==2.9.9
|
||||
python-multipart==0.0.12
|
||||
textblob==0.18.0.post0
|
||||
tweepy==4.15.0
|
||||
uvicorn[standard]==0.32.1
|
||||
websockets==13.1
|
||||
|
||||
@@ -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,483 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
_pollStop: null,
|
||||
viewMode: 'cockpit',
|
||||
visualMode: 'table',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const saved = localStorage.getItem('rc-visual-mode');
|
||||
if (saved === 'table' || saved === 'bars' || saved === 'cards') {
|
||||
this.visualMode = saved;
|
||||
} else if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
this.visualMode = 'cards';
|
||||
}
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (window.CockpitLive) {
|
||||
this._pollStop = CockpitLive.startPolling(async () => {
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
}, 45000);
|
||||
}
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
|
||||
setVisualMode(mode) {
|
||||
if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return;
|
||||
this.visualMode = mode;
|
||||
localStorage.setItem('rc-visual-mode', mode);
|
||||
},
|
||||
|
||||
styleLabel(style) {
|
||||
const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' };
|
||||
return map[style] || 'Neutraal';
|
||||
},
|
||||
|
||||
rankedRows() {
|
||||
const rows = this.visualRows().slice();
|
||||
rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0));
|
||||
return rows;
|
||||
},
|
||||
|
||||
cardRows() {
|
||||
return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim());
|
||||
},
|
||||
|
||||
async setCockpitStyle(style) {
|
||||
const p = this.selectedProject;
|
||||
if (!p) return;
|
||||
if (!p.db_id) {
|
||||
p.row_style = style;
|
||||
if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + p.db_id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
p.row_style = style;
|
||||
const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p));
|
||||
if (pi >= 0) this.projects[pi].row_style = style;
|
||||
const di = this.dbProjects.findIndex((x) => x.id === p.db_id);
|
||||
if (di >= 0) this.dbProjects[di].row_style = style;
|
||||
if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success');
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
|
||||
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
|
||||
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
|
||||
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
|
||||
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
|
||||
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
|
||||
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
|
||||
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
|
||||
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
|
||||
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
|
||||
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
|
||||
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
|
||||
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
|
||||
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
|
||||
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
|
||||
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
|
||||
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
|
||||
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
|
||||
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
|
||||
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
|
||||
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
|
||||
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
|
||||
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
|
||||
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
|
||||
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
|
||||
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
|
||||
c9ibkVARig==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -138,6 +138,12 @@
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1200;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.agent-events-backdrop.is-open {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.agent-events-drawer {
|
||||
position: fixed;
|
||||
@@ -208,3 +214,111 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
|
||||
|
||||
/* Live working animations */
|
||||
.agent-soul-card {
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(12, 18, 28, 0.85);
|
||||
}
|
||||
.agent-soul-card.is-working {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12);
|
||||
animation: agent-card-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy {
|
||||
border-color: rgba(251, 191, 36, 0.65);
|
||||
box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12);
|
||||
animation: agent-card-pulse-busy 1.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap,
|
||||
.agent-soul-card.is-busy .agent-char-wrap {
|
||||
animation: agent-avatar-bob 1.1s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap::after,
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%);
|
||||
animation: agent-glow-sweep 2.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%);
|
||||
}
|
||||
.agent-activity-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 12px;
|
||||
margin-left: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.agent-activity-eq span {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #38bdf8;
|
||||
animation: agent-eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; }
|
||||
.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; }
|
||||
.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; }
|
||||
.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; }
|
||||
.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; }
|
||||
|
||||
.agent-status-pill.active {
|
||||
border-color: rgba(34,197,94,0.45);
|
||||
color: #86efac;
|
||||
background: rgba(34,197,94,0.1);
|
||||
}
|
||||
.agent-status-pill.busy {
|
||||
border-color: rgba(245,158,11,0.5);
|
||||
color: #fcd34d;
|
||||
background: rgba(245,158,11,0.12);
|
||||
animation: agent-pill-blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-status-pill.idle .dot { background: #64748b; }
|
||||
.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; }
|
||||
.agent-status-pill.offline { color: #64748b; }
|
||||
|
||||
@keyframes agent-card-pulse {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
@keyframes agent-card-pulse-busy {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-3px) scale(1.01); }
|
||||
}
|
||||
@keyframes agent-avatar-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
@keyframes agent-glow-sweep {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
@keyframes agent-eq {
|
||||
0%, 100% { transform: scaleY(0.55); opacity: 0.7; }
|
||||
50% { transform: scaleY(1.25); opacity: 1; }
|
||||
}
|
||||
@keyframes agent-pill-blink {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||||
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
|
||||
}
|
||||
|
||||
/* Brighter, readable agent avatars on dark UI */
|
||||
.agent-char-wrap {
|
||||
background: linear-gradient(145deg, #1e4a6e, #163a58) !important;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35) !important;
|
||||
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
.agent-char-wrap .agent-char-svg {
|
||||
filter: brightness(1.18) contrast(1.08) saturate(1.15);
|
||||
}
|
||||
.agent-soul-card .agent-char-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
@@ -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,698 @@
|
||||
/* Foodlinkk War Room — compact, one viewport */
|
||||
.wr-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
height: calc(100vh - 150px);
|
||||
max-height: calc(100vh - 150px);
|
||||
}
|
||||
.wr-page-header { margin-bottom: 0.4rem; }
|
||||
.wr-page-header h1 { font-size: 1.2rem; margin: 0; }
|
||||
.wr-page-header .hm-sub { margin: 0.15rem 0 0; font-size: 0.85rem; }
|
||||
|
||||
.wr-roster {
|
||||
border-right: 1px solid rgba(255,255,255,0.08);
|
||||
padding-right: 0.4rem;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.wr-roster h3 {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.wr-room-btn, .wr-agent-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
padding: 0.32rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wr-room-btn:hover, .wr-agent-btn:hover,
|
||||
.wr-room-btn.active, .wr-agent-btn.active {
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
border-color: rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
.wr-agent-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||
.wr-agent-meta strong { font-size: 0.8rem; }
|
||||
.wr-agent-meta small { opacity: 0.65; font-size: 0.68rem; }
|
||||
.wr-roster .agent-char-wrap { width: 40px; height: 40px; flex: 0 0 40px; }
|
||||
|
||||
.wr-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.wr-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 0.5rem; margin-bottom: 0.35rem; flex: 0 0 auto;
|
||||
}
|
||||
.wr-head h2 { margin: 0; font-size: 1rem; letter-spacing: 0.04em; }
|
||||
.wr-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
|
||||
.wr-log {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: rgba(0,0,0,0.22);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
.wr-empty { opacity: 0.6; margin: 0.35rem 0; font-size: 0.85rem; }
|
||||
|
||||
.wr-bubble {
|
||||
display: flex; gap: 0.45rem; margin-bottom: 0.45rem;
|
||||
}
|
||||
.wr-bubble.is-user { flex-direction: row-reverse; }
|
||||
.wr-bubble.is-system { opacity: 0.85; }
|
||||
.wr-avatar { width: 44px; height: 44px; flex: 0 0 44px; }
|
||||
.wr-avatar img, .wr-avatar .agent-char-wrap {
|
||||
width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
|
||||
}
|
||||
.wr-avatar .agent-char-wrap svg { display: block; width: 44px; height: 44px; }
|
||||
.wr-avatar-fallback {
|
||||
display: grid; place-items: center; width: 44px; height: 44px;
|
||||
border-radius: 50%; background: #1e3a5f; font-size: 0.65rem; font-weight: 700;
|
||||
}
|
||||
.wr-bubble-body {
|
||||
max-width: min(680px, 85%);
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.wr-bubble.is-user .wr-bubble-body {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border-color: rgba(34, 197, 94, 0.28);
|
||||
}
|
||||
.wr-bubble.is-agent .wr-bubble-body {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border-color: rgba(56, 189, 248, 0.22);
|
||||
}
|
||||
.wr-bubble.is-typing .wr-bubble-body { border-style: dashed; }
|
||||
.wr-bubble-meta { font-size: 0.68rem; opacity: 0.7; margin-bottom: 0.1rem; }
|
||||
.wr-bubble-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wr-typing-cursor::after {
|
||||
content: "▌";
|
||||
animation: wrBlink 1s steps(1) infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
@keyframes wrBlink { 50% { opacity: 0; } }
|
||||
|
||||
.wr-attach-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; }
|
||||
.wr-attach-card {
|
||||
display: block; max-width: 300px; text-decoration: none; color: inherit;
|
||||
border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; overflow: hidden;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.wr-attach-card img { display: block; width: 100%; max-height: 220px; object-fit: cover; }
|
||||
.wr-attach-card span {
|
||||
display: block; padding: 0.25rem 0.4rem; font-size: 0.7rem; opacity: 0.85;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wr-composer {
|
||||
margin-top: 0.4rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.35rem;
|
||||
align-items: end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wr-composer-tools {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
|
||||
}
|
||||
.wr-pending {
|
||||
display: flex; flex-wrap: wrap; gap: 0.3rem; grid-column: 1 / -1;
|
||||
}
|
||||
.wr-pending-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
font-size: 0.7rem; padding: 0.15rem 0.4rem;
|
||||
border-radius: 999px; background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.wr-pending-chip button {
|
||||
border: 0; background: transparent; color: inherit; cursor: pointer; opacity: 0.7;
|
||||
}
|
||||
.wr-emoji-pop {
|
||||
display: flex; flex-wrap: wrap; gap: 0.15rem;
|
||||
max-width: 280px; padding: 0.3rem;
|
||||
background: #0f172a; border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wr-emoji-pop button {
|
||||
border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; line-height: 1;
|
||||
padding: 0.12rem;
|
||||
}
|
||||
|
||||
/* Call overlay */
|
||||
.wr-call-overlay {
|
||||
position: fixed; inset: 0; z-index: 11000;
|
||||
background: rgba(3, 8, 18, 0.82);
|
||||
display: none; align-items: center; justify-content: center;
|
||||
padding: 1rem; pointer-events: none;
|
||||
}
|
||||
.wr-call-panel {
|
||||
width: min(1100px, 96vw);
|
||||
height: min(780px, 92vh);
|
||||
background: #0b1220;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 14px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
.wr-call-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 0.75rem; padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.wr-call-head h3 { margin: 0; font-size: 1rem; }
|
||||
.wr-call-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
.wr-call-frame {
|
||||
flex: 1; border: 0; width: 100%; background: #000;
|
||||
}
|
||||
.wr-call-hint {
|
||||
margin: 0; padding: 0.45rem 0.85rem; font-size: 0.78rem; opacity: 0.75;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.wr-popup-body {
|
||||
margin: 0; height: 100vh; overflow: hidden;
|
||||
background: #070b14; color: #e8eef8;
|
||||
}
|
||||
.wr-popup-shell { padding: 0.55rem; height: 100vh; box-sizing: border-box; }
|
||||
.wr-popup-shell .wr-layout {
|
||||
height: calc(100vh - 1.1rem);
|
||||
max-height: calc(100vh - 1.1rem);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wr-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
.wr-roster { max-height: 140px; border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.wr-main { height: calc(100vh - 280px); }
|
||||
}
|
||||
|
||||
|
||||
.wr-composer-wrap { position: relative; grid-column: 1 / 2; }
|
||||
.wr-mention-pop {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: calc(100% + 4px);
|
||||
max-height: 220px; overflow: auto;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255,215,0,0.35);
|
||||
border-radius: 10px;
|
||||
z-index: 20;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
}
|
||||
.wr-mention-item {
|
||||
width: 100%; display: flex; align-items: center; gap: 0.45rem;
|
||||
text-align: left; border: 0; background: transparent; color: inherit;
|
||||
padding: 0.4rem 0.55rem; cursor: pointer;
|
||||
}
|
||||
.wr-mention-item:hover, .wr-mention-item.active {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
}
|
||||
.wr-mention-item strong { font-size: 0.85rem; }
|
||||
.wr-mention-item small { opacity: 0.65; margin-left: 0.25rem; }
|
||||
.wr-agent-btn .wr-dm-hint {
|
||||
margin-left: auto; font-size: 0.65rem; opacity: 0.45;
|
||||
}
|
||||
|
||||
.wr-bubble-meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
|
||||
.wr-msg-del {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.wr-bubble:hover .wr-msg-del { opacity: 0.55; }
|
||||
.wr-msg-del:hover { opacity: 1 !important; color: #e74c3c; }
|
||||
.wr-call-join {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
min-height: 280px;
|
||||
}
|
||||
.wr-call-title { margin: 0; font-size: 1.15rem; font-weight: 600; }
|
||||
.wr-call-join-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
||||
.wr-call-link {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.75;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.wr-main { position: relative; z-index: 1; }
|
||||
.wr-composer {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.wr-composer textarea,
|
||||
.wr-composer .form-input {
|
||||
pointer-events: auto !important;
|
||||
position: relative;
|
||||
z-index: 7;
|
||||
}
|
||||
.wr-log { position: relative; z-index: 1; }
|
||||
.wr-call-overlay {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.btn.is-recording, button.is-recording {
|
||||
background: #c0392b !important;
|
||||
color: #fff !important;
|
||||
animation: wr-mic-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wr-mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(192,57,43,0.5); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(192,57,43,0); }
|
||||
}
|
||||
|
||||
.wr-voice-overlay {
|
||||
position: fixed !important; inset: 0 !important; z-index: 11000 !important;
|
||||
background: rgba(3, 8, 18, 0.88);
|
||||
display: none !important;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
.wr-voice-overlay.is-open {
|
||||
display: flex !important;
|
||||
pointer-events: auto !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
.wr-voice-panel {
|
||||
width: min(920px, 96vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
background: #0b1220;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.1rem 1.15rem;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.5);
|
||||
color: #e8eef8;
|
||||
}
|
||||
.wr-voice-head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.wr-voice-head h3 { margin: 0; font-size: 1.15rem; }
|
||||
.wr-voice-head-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.wr-voice-status {
|
||||
margin: 0.85rem 0 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.wr-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wr-voice-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(255,255,255,0.03);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, opacity .15s;
|
||||
}
|
||||
.wr-voice-card.is-human { cursor: default; border-color: rgba(56, 189, 248, 0.35); }
|
||||
.wr-voice-card.is-live {
|
||||
border-color: rgba(34, 197, 94, 0.7);
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.wr-voice-card.is-off { opacity: 0.45; }
|
||||
.wr-voice-avatar {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; background: #1e293b;
|
||||
}
|
||||
.wr-voice-avatar-human {
|
||||
font-weight: 700; font-size: 0.85rem; background: #0ea5e9; color: #041018;
|
||||
}
|
||||
.wr-voice-avatar .agent-char-wrap, .wr-voice-avatar.agent-char-wrap { width: 52px; height: 52px; }
|
||||
.wr-voice-card strong { font-size: 0.82rem; }
|
||||
.wr-voice-card small { font-size: 0.7rem; }
|
||||
.wr-voice-hint { font-size: 0.8rem; opacity: 0.8; margin: 0.75rem 0 0.25rem; }
|
||||
.wr-voice-ptt { display: flex; justify-content: center; margin: 1rem 0 0.5rem; }
|
||||
.wr-ptt-btn {
|
||||
min-width: min(320px, 90vw);
|
||||
min-height: 64px;
|
||||
border: 0; border-radius: 999px;
|
||||
font-size: 1.05rem; font-weight: 600;
|
||||
background: linear-gradient(135deg, #0ea5e9, #2563eb);
|
||||
color: #fff; cursor: pointer;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
}
|
||||
.wr-ptt-btn.is-recording {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
animation: wr-mic-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.wr-ptt-btn.is-busy { opacity: 0.7; cursor: wait; }
|
||||
.wr-voice-foot {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 0.75rem;
|
||||
flex-wrap: wrap; margin-top: 0.85rem; padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* War Room roster / message avatars — brighter */
|
||||
.wr-roster .agent-char-wrap,
|
||||
.wr-avatar .agent-char-wrap {
|
||||
background: linear-gradient(145deg, #245a82, #1a4568) !important;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4) !important;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wr-roster .agent-char-wrap .agent-char-svg,
|
||||
.wr-avatar .agent-char-wrap .agent-char-svg {
|
||||
filter: brightness(1.22) contrast(1.1) saturate(1.2);
|
||||
}
|
||||
.wr-chan-icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #38bdf8;
|
||||
margin-right: 0.35rem;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
.wr-chan-icon svg { width: 16px; height: 16px; display: block; }
|
||||
|
||||
/* Agents vertical tabs — SVG icons */
|
||||
.vtabs-nav .vtab-icon-svg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
color: #7dd3fc;
|
||||
background: color-mix(in srgb, #22d3ee 14%, rgba(15,23,42,.6));
|
||||
border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent);
|
||||
font-size: 0;
|
||||
}
|
||||
.vtabs-nav .vtab-icon-svg svg { width: 15px; height: 15px; display: block; }
|
||||
.vtabs-nav .vtab-btn.active .vtab-icon-svg {
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, #22d3ee 45%, #0b1220);
|
||||
border-color: #22d3ee;
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35);
|
||||
}
|
||||
|
||||
/* War Room richer toolbar / search / dates */
|
||||
.wr-head-title { min-width: 0; }
|
||||
.wr-msg-count { display: block; margin-top: 0.15rem; font-size: 0.7rem; opacity: 0.65; }
|
||||
.wr-tool-btn.active {
|
||||
border-color: rgba(34, 211, 238, 0.55) !important;
|
||||
background: rgba(34, 211, 238, 0.15) !important;
|
||||
}
|
||||
.wr-tools-wrap { position: relative; }
|
||||
.wr-tools-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 4px); z-index: 40;
|
||||
min-width: 200px;
|
||||
background: #0f2438;
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
|
||||
display: flex; flex-direction: column; gap: 0.15rem;
|
||||
}
|
||||
.wr-tools-menu button {
|
||||
text-align: left; background: transparent; border: 0; color: #e2e8f0;
|
||||
padding: 0.45rem 0.55rem; border-radius: 7px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.wr-tools-menu button:hover { background: rgba(56, 189, 248, 0.12); }
|
||||
.wr-tools-menu button.wr-danger { color: #fda4af; }
|
||||
.wr-tools-menu button.wr-danger:hover { background: rgba(244, 63, 94, 0.15); }
|
||||
.wr-tools-menu hr {
|
||||
border: 0; border-top: 1px solid rgba(148,163,184,0.2); margin: 0.25rem 0;
|
||||
}
|
||||
.wr-searchbar {
|
||||
display: flex; gap: 0.4rem; align-items: center;
|
||||
margin-bottom: 0.4rem; flex: 0 0 auto;
|
||||
}
|
||||
.wr-searchbar .form-input { flex: 1; min-width: 0; font-size: 0.85rem; padding: 0.4rem 0.65rem; }
|
||||
.wr-day-sep {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0.75rem 0 0.55rem;
|
||||
}
|
||||
.wr-day-sep span {
|
||||
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #7dd3fc;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
padding: 0.2rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wr-bubble-meta {
|
||||
display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
|
||||
}
|
||||
.wr-who { font-weight: 600; color: #e2e8f0; opacity: 1; }
|
||||
.wr-handle { opacity: 0.55; font-size: 0.65rem; }
|
||||
.wr-time {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.55;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.wr-bubble.is-user .wr-time { margin-left: 0; margin-right: auto; order: -1; }
|
||||
|
||||
.wr-a2a-btn {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wr-a2a-btn.is-on {
|
||||
background: rgba(34, 197, 94, 0.18) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.45) !important;
|
||||
color: #86efac !important;
|
||||
}
|
||||
.wr-a2a-btn.is-off {
|
||||
background: rgba(148, 163, 184, 0.12) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35) !important;
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
.wr-mobile-bar { display: none; }
|
||||
|
||||
/* CEO inbox */
|
||||
.wr-ceo-btn { position: relative; }
|
||||
.wr-ceo-badge {
|
||||
display: inline-grid; place-items: center;
|
||||
min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem;
|
||||
margin-left: 0.25rem; border-radius: 999px;
|
||||
background: #f43f5e; color: #fff; font-size: 0.65rem; font-weight: 700;
|
||||
}
|
||||
.wr-ceo-overlay {
|
||||
position: absolute; inset: 0; z-index: 80;
|
||||
background: rgba(2, 8, 20, 0.72);
|
||||
display: flex; align-items: stretch; justify-content: flex-end;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wr-main { position: relative; }
|
||||
.wr-ceo-panel {
|
||||
width: min(420px, 100%);
|
||||
background: linear-gradient(180deg, #0f2744, #0a1c33);
|
||||
border-left: 1px solid rgba(56, 189, 248, 0.35);
|
||||
box-shadow: -12px 0 40px rgba(0,0,0,0.45);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.wr-ceo-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.65rem; }
|
||||
.wr-ceo-head h3 { margin: 0; font-size: 0.95rem; }
|
||||
.wr-ceo-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.wr-ceo-list { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.wr-ceo-item {
|
||||
border: 1px solid rgba(148,163,184,0.18);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
.wr-ceo-item.unread {
|
||||
border-color: rgba(251, 191, 36, 0.55);
|
||||
box-shadow: inset 3px 0 0 #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
.wr-ceo-item-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
|
||||
font-size: 0.72rem; opacity: 0.85; margin-bottom: 0.35rem;
|
||||
}
|
||||
.wr-ceo-unread-dot {
|
||||
background: #fbbf24; color: #111; font-weight: 700;
|
||||
padding: 0.05rem 0.4rem; border-radius: 999px; font-size: 0.65rem;
|
||||
}
|
||||
.wr-ceo-item-body {
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 0.86rem; line-height: 1.35;
|
||||
}
|
||||
.wr-ceo-item-actions { margin-top: 0.4rem; }
|
||||
.wr-bubble.is-ceo-direct .wr-bubble-body {
|
||||
border-color: rgba(251, 191, 36, 0.55) !important;
|
||||
box-shadow: inset 3px 0 0 #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.08) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Reply */
|
||||
.wr-msg-reply {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
.wr-bubble:hover .wr-msg-reply { opacity: 0.7; }
|
||||
.wr-msg-reply:hover { opacity: 1 !important; color: var(--accent, #3b82f6); }
|
||||
.wr-reply-quote {
|
||||
border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
|
||||
padding: 0.2rem 0.45rem;
|
||||
margin: 0.15rem 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.85;
|
||||
background: color-mix(in srgb, var(--surface-2, #111) 60%, transparent);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.wr-reply-quote-who { display: block; font-weight: 600; margin-bottom: 0.1rem; }
|
||||
.wr-reply-quote-text { display: block; opacity: 0.85; white-space: pre-wrap; }
|
||||
.wr-reply-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--accent, #3b82f6);
|
||||
background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.wr-reply-bar-text { flex: 1; min-width: 0; }
|
||||
.wr-reply-bar-text strong { display: block; margin-bottom: 0.1rem; }
|
||||
.wr-reply-bar-text span {
|
||||
display: block;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.wr-bubble.is-reply-target .wr-bubble-body {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent, #38bdf8) 70%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, #38bdf8 18%, transparent);
|
||||
}
|
||||
.wr-msg-wrap { scroll-margin: 4rem; }
|
||||
|
||||
|
||||
.wr-log-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wr-log-wrap .wr-log {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.wr-jump-latest {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
z-index: 25;
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
background: rgba(8, 20, 36, 0.94);
|
||||
color: #e0f2fe;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
}
|
||||
.wr-jump-latest:hover {
|
||||
background: rgba(14, 40, 64, 0.98);
|
||||
border-color: rgba(56, 189, 248, 0.7);
|
||||
}
|
||||
|
||||
|
||||
.wr-history-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.45rem 0.35rem 0.65rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: linear-gradient(180deg, rgba(8,12,18,0.96), rgba(8,12,18,0.72) 70%, transparent);
|
||||
}
|
||||
.wr-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.wr-day-sep {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
.wr-day-sep span {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -1,50 +1,333 @@
|
||||
.analytics-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
/* Foodlinkk Intelligence Analytics — lush neo dashboard */
|
||||
.ax-shell {
|
||||
--ax-bg: #070b12;
|
||||
--ax-panel: rgba(12, 18, 28, 0.88);
|
||||
--ax-line: rgba(125, 211, 252, 0.16);
|
||||
--ax-cyan: #38bdf8;
|
||||
--ax-lime: #b8ff3c;
|
||||
--ax-amber: #fbbf24;
|
||||
--ax-rose: #fb7185;
|
||||
--ax-text: #e8eef7;
|
||||
--ax-muted: #8fa3b8;
|
||||
--ax-font: "Outfit", "Segoe UI", sans-serif;
|
||||
--ax-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-family: var(--ax-font);
|
||||
color: var(--ax-text);
|
||||
margin: -0.25rem -0.5rem 0;
|
||||
padding: 0 0 2.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.analytics-filters label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.analytics-filters select,
|
||||
.analytics-filters input {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.analytics-main { min-width: 0; }
|
||||
.analytics-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
.ax-shell * { box-sizing: border-box; }
|
||||
.ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; }
|
||||
.ax-muted { color: var(--ax-muted); font-size: 0.82rem; }
|
||||
.ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; }
|
||||
|
||||
.ax-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--ax-line);
|
||||
background:
|
||||
radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%),
|
||||
radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%),
|
||||
linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%);
|
||||
padding: 1.4rem 1.5rem 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: ax-fade-up .55s ease both;
|
||||
}
|
||||
.analytics-kpis .kpi-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
.ax-hero-glow {
|
||||
position: absolute; inset: auto -20% -40% 40%; height: 70%;
|
||||
background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%);
|
||||
pointer-events: none; filter: blur(8px);
|
||||
}
|
||||
.analytics-kpis .kpi-card strong {
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.analytics-kpis .kpi-card span {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.analytics-charts {
|
||||
.ax-hero-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
grid-template-columns: 1.4fr auto 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-panel canvas { max-height: 220px; }
|
||||
.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; }
|
||||
@media (max-width: 900px) {
|
||||
.analytics-shell { grid-template-columns: 1fr; }
|
||||
.ax-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ax-hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.ax-live {
|
||||
display: inline-flex; align-items: center;
|
||||
margin-left: 0.55rem; vertical-align: middle;
|
||||
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
padding: 0.22rem 0.55rem; border-radius: 999px;
|
||||
background: rgba(184,255,60,0.12); border: 1px solid rgba(184,255,60,0.35);
|
||||
color: #d9ff8b; animation: ax-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.ax-lead { margin: 0.65rem 0 0; max-width: 36rem; color: #b6c7d8; font-size: 1rem; line-height: 1.5; }
|
||||
.ax-goal { margin: 0.55rem 0 0; color: #7dd3fc; font-size: 0.82rem; opacity: 0.9; }
|
||||
|
||||
.ax-pulse-orb { text-align: center; position: relative; width: 150px; justify-self: center; }
|
||||
.ax-ring { width: 150px; height: 150px; transform: rotate(-90deg); }
|
||||
.ax-ring-track { fill: none; stroke: rgba(148,163,184,0.15); stroke-width: 8; }
|
||||
.ax-ring-value {
|
||||
fill: none; stroke: var(--ax-cyan); stroke-width: 8; stroke-linecap: round;
|
||||
transition: stroke-dasharray .8s ease;
|
||||
filter: drop-shadow(0 0 8px rgba(56,189,248,0.45));
|
||||
}
|
||||
.tone-good .ax-ring-value { stroke: var(--ax-lime); filter: drop-shadow(0 0 8px rgba(184,255,60,0.4)); }
|
||||
.tone-mid .ax-ring-value { stroke: var(--ax-amber); }
|
||||
.tone-low .ax-ring-value { stroke: var(--ax-rose); }
|
||||
.ax-pulse-core {
|
||||
position: absolute; inset: 0; display: grid; place-content: center; text-align: center;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.ax-pulse-core strong { font-size: 2.1rem; font-weight: 700; line-height: 1; }
|
||||
.ax-pulse-core span { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ax-muted); }
|
||||
.ax-pulse-label { margin: 0.15rem 0 0; font-size: 0.75rem; color: #cbd5e1; }
|
||||
|
||||
.ax-hero-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: flex-end; align-items: center; }
|
||||
.ax-btn {
|
||||
appearance: none; border: 1px solid rgba(148,163,184,0.28);
|
||||
background: rgba(8,12,20,0.55); color: #e2e8f0;
|
||||
border-radius: 999px; padding: 0.45rem 0.85rem; font: inherit; font-size: 0.78rem;
|
||||
cursor: pointer; text-decoration: none; transition: border-color .15s, background .15s, transform .12s;
|
||||
}
|
||||
.ax-btn:hover { border-color: #38bdf8; background: rgba(14,165,233,0.12); transform: translateY(-1px); }
|
||||
.ax-btn-primary {
|
||||
border-color: rgba(184,255,60,0.45);
|
||||
background: linear-gradient(135deg, rgba(184,255,60,0.18), rgba(56,189,248,0.12));
|
||||
color: #f8ffe8;
|
||||
}
|
||||
.ax-updated { width: 100%; text-align: right; color: #64748b; font-size: 0.7rem; }
|
||||
|
||||
.ax-filters {
|
||||
margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid rgba(148,163,184,0.12);
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.65rem; align-items: end;
|
||||
}
|
||||
.ax-filters label { font-size: 0.72rem; color: #94a3b8; display: grid; gap: 0.25rem; }
|
||||
.ax-filters select, .ax-filters input {
|
||||
width: 100%; border-radius: 8px; border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #0a1018; color: #e2e8f0; padding: 0.4rem 0.5rem; font: inherit;
|
||||
}
|
||||
|
||||
.ax-signals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: ax-fade-up .6s ease both .08s;
|
||||
}
|
||||
.ax-signal {
|
||||
position: relative; overflow: hidden;
|
||||
background: var(--ax-panel);
|
||||
border: 1px solid var(--ax-line);
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 0.9rem 0.75rem;
|
||||
min-height: 96px;
|
||||
}
|
||||
.ax-signal::after {
|
||||
content: ""; position: absolute; inset: auto 0 0 0; height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--ax-cyan), transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ax-signal.good::after { background: linear-gradient(90deg, transparent, var(--ax-lime), transparent); }
|
||||
.ax-signal.warn::after { background: linear-gradient(90deg, transparent, var(--ax-amber), transparent); }
|
||||
.ax-signal-label { display: block; font-size: 0.68rem; color: #94a3b8; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.ax-signal strong { display: block; margin-top: 0.35rem; font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.ax-signal em { display: block; margin-top: 0.25rem; font-style: normal; font-size: 0.72rem; color: #64748b; }
|
||||
|
||||
.ax-split {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem;
|
||||
animation: ax-fade-up .65s ease both .12s;
|
||||
}
|
||||
.ax-col {
|
||||
background: var(--ax-panel);
|
||||
border: 1px solid var(--ax-line);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
min-height: 320px;
|
||||
}
|
||||
.ax-col-win { box-shadow: inset 3px 0 0 rgba(184,255,60,0.55); }
|
||||
.ax-col-opp { box-shadow: inset 3px 0 0 rgba(251,191,36,0.55); }
|
||||
.ax-col-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.ax-col-head h2, .ax-hermes h2, .ax-radar h2 {
|
||||
margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.ax-col-head span, .ax-pill {
|
||||
font-size: 0.72rem; color: #94a3b8;
|
||||
padding: 0.2rem 0.55rem; border-radius: 999px;
|
||||
border: 1px solid rgba(148,163,184,0.2);
|
||||
}
|
||||
|
||||
.ax-insight {
|
||||
display: block; text-decoration: none; color: inherit;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
background: rgba(6, 10, 16, 0.55);
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem 0.85rem;
|
||||
margin-bottom: 0.55rem;
|
||||
transition: transform .14s, border-color .14s, background .14s;
|
||||
}
|
||||
.ax-insight:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(56,189,248,0.35);
|
||||
background: rgba(14,165,233,0.08);
|
||||
}
|
||||
.ax-insight-top { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.ax-insight-top strong { font-size: 0.95rem; line-height: 1.3; }
|
||||
.ax-score {
|
||||
font-size: 0.85rem; color: #d9ff8b; min-width: 2rem; text-align: right;
|
||||
}
|
||||
.ax-insight.opp .ax-score { color: #fcd34d; }
|
||||
.ax-insight p { margin: 0.4rem 0 0.55rem; font-size: 0.82rem; color: #a7b7c8; line-height: 1.45; }
|
||||
.ax-insight-foot { display: grid; gap: 0.35rem; }
|
||||
.ax-insight-foot > span:first-child { font-size: 0.72rem; color: #7dd3fc; }
|
||||
.ax-bar, .ax-mini-bar {
|
||||
display: block; height: 4px; border-radius: 999px; background: rgba(148,163,184,0.15); overflow: hidden;
|
||||
}
|
||||
.ax-bar i, .ax-mini-bar i {
|
||||
display: block; height: 100%; border-radius: inherit;
|
||||
background: linear-gradient(90deg, #84cc16, #b8ff3c);
|
||||
}
|
||||
.ax-bar.opp i { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.ax-tag {
|
||||
display: inline-block; font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 0.12rem 0.4rem; border-radius: 999px; margin-right: 0.25rem;
|
||||
background: rgba(251,191,36,0.12); color: #fcd34d; border: 1px solid rgba(251,191,36,0.25);
|
||||
}
|
||||
.ax-empty { color: #64748b; font-size: 0.85rem; padding: 1rem 0; }
|
||||
|
||||
.ax-band { margin-bottom: 1rem; animation: ax-fade-up .7s ease both .16s; }
|
||||
.ax-att-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
|
||||
.ax-att-chip {
|
||||
flex: 1 1 220px; text-decoration: none; color: inherit;
|
||||
border: 1px solid rgba(251,113,133,0.28);
|
||||
background: rgba(251,113,133,0.08);
|
||||
border-radius: 12px; padding: 0.7rem 0.8rem;
|
||||
}
|
||||
.ax-att-chip strong { display: block; font-size: 0.85rem; margin-bottom: 0.2rem; }
|
||||
.ax-att-chip span { font-size: 0.75rem; color: #cbd5e1; }
|
||||
.ax-attention h3 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #fda4af; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
|
||||
.ax-hermes {
|
||||
border: 1px solid var(--ax-line);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(700px 220px at 0% 0%, rgba(56,189,248,0.12), transparent 55%),
|
||||
var(--ax-panel);
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
}
|
||||
.ax-findings-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.65rem; margin-top: 0.75rem;
|
||||
}
|
||||
.ax-finding {
|
||||
border-radius: 14px; border: 1px solid rgba(148,163,184,0.16);
|
||||
background: rgba(5,9,14,0.65); padding: 0.85rem;
|
||||
}
|
||||
.ax-finding.kind-win { border-color: rgba(184,255,60,0.3); }
|
||||
.ax-finding.kind-opportunity { border-color: rgba(251,191,36,0.35); }
|
||||
.ax-finding-top { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
|
||||
.ax-finding-top time { font-size: 0.68rem; color: #64748b; }
|
||||
.ax-finding h4 { margin: 0; font-size: 0.95rem; }
|
||||
.ax-finding p { margin: 0.4rem 0 0.55rem; font-size: 0.8rem; color: #a7b7c8; line-height: 1.45; }
|
||||
.ax-finding-foot { display: flex; justify-content: space-between; font-size: 0.7rem; color: #64748b; }
|
||||
.ax-findings-empty { margin-top: 0.75rem; }
|
||||
.ax-findings-placeholder {
|
||||
border: 1px dashed rgba(56,189,248,0.35);
|
||||
border-radius: 14px; padding: 1.1rem 1.2rem;
|
||||
background: rgba(14,165,233,0.05);
|
||||
}
|
||||
.ax-findings-placeholder strong { display: block; margin-bottom: 0.3rem; }
|
||||
.ax-findings-placeholder p { margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
|
||||
|
||||
.ax-radar {
|
||||
border: 1px solid var(--ax-line);
|
||||
border-radius: 18px;
|
||||
background: var(--ax-panel);
|
||||
padding: 1rem 1.1rem 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: ax-fade-up .75s ease both .2s;
|
||||
}
|
||||
.ax-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ax-chart-card {
|
||||
grid-column: span 2;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(5,9,14,0.55);
|
||||
padding: 0.75rem 0.8rem 0.6rem;
|
||||
}
|
||||
.ax-chart-card.wide { grid-column: span 6; }
|
||||
.ax-chart-card h3 { margin: 0 0 0.45rem; font-size: 0.82rem; color: #cbd5e1; font-weight: 600; }
|
||||
.ax-chart-wrap { position: relative; height: 210px; }
|
||||
.ax-chart-card.wide .ax-chart-wrap { height: 180px; }
|
||||
|
||||
.ax-lists {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.85rem;
|
||||
animation: ax-fade-up .8s ease both .24s;
|
||||
}
|
||||
.ax-list-card {
|
||||
border: 1px solid var(--ax-line);
|
||||
border-radius: 16px;
|
||||
background: var(--ax-panel);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.ax-list-card h3 { margin: 0 0 0.75rem; font-size: 0.92rem; }
|
||||
.ax-list-card ul { list-style: none; margin: 0; padding: 0; }
|
||||
.ax-list-card li {
|
||||
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
|
||||
padding: 0.55rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
}
|
||||
.ax-rank {
|
||||
width: 1.5rem; height: 1.5rem; border-radius: 8px;
|
||||
display: grid; place-items: center; font-size: 0.72rem;
|
||||
background: rgba(56,189,248,0.12); color: #7dd3fc;
|
||||
}
|
||||
.ax-list-card li strong { display: block; font-size: 0.82rem; }
|
||||
.ax-list-card li em { font-style: normal; color: #fbbf24; font-size: 0.85rem; }
|
||||
.ax-events li { grid-template-columns: 1fr auto; }
|
||||
.ax-events li span { display: block; font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
|
||||
.ax-events time { font-size: 0.68rem; color: #64748b; }
|
||||
.ax-dl { margin: 0; display: grid; gap: 0.55rem; }
|
||||
.ax-dl > div {
|
||||
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||
padding: 0.45rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
}
|
||||
.ax-dl dt { color: #94a3b8; font-size: 0.8rem; }
|
||||
.ax-dl dd { margin: 0; font-size: 0.9rem; color: #e2e8f0; }
|
||||
|
||||
@keyframes ax-fade-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes ax-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(184,255,60,0.0); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(184,255,60,0.08); }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ax-hero-grid { grid-template-columns: 1fr; }
|
||||
.ax-hero-actions { justify-content: flex-start; }
|
||||
.ax-signals { grid-template-columns: repeat(3, 1fr); }
|
||||
.ax-split, .ax-lists { grid-template-columns: 1fr; }
|
||||
.ax-charts { grid-template-columns: 1fr 1fr; }
|
||||
.ax-chart-card, .ax-chart-card.wide { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ax-signals { grid-template-columns: 1fr 1fr; }
|
||||
.ax-charts { grid-template-columns: 1fr; }
|
||||
.ax-chart-card, .ax-chart-card.wide { grid-column: span 1; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
:root { --sidebar-width: 280px; }
|
||||
|
||||
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
|
||||
|
||||
.app-shell {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width, 280px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0.75rem 0.6rem 0;
|
||||
background: #080c12;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-left: var(--sidebar-width, 280px);
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar-brand,
|
||||
.persona-switch.brand-switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-sidebar-brand {
|
||||
padding: 0.25rem 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.app-sidebar-brand .brand-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.app-sidebar-brand .brand-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.persona-switch.brand-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.35rem 0.65rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.persona-btn {
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
|
||||
.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
|
||||
|
||||
.persona-nav {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 0.75rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.app-sidebar-group {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.app-sidebar-label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
padding: 0.2rem 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
/* Base pill — accent via modifier class */
|
||||
.app-sidebar a.nav-pill {
|
||||
--nav-accent: #64748b;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin: 0 0 0.2rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
color: #cbd5e1 !important;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transform: none !important;
|
||||
animation: none !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
overflow: visible;
|
||||
isolation: auto;
|
||||
}
|
||||
.app-sidebar a.nav-pill::before,
|
||||
.app-sidebar a.nav-pill::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.app-sidebar a.nav-pill:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #f1f5f9 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-sidebar a.nav-pill.active {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border-color: var(--nav-accent);
|
||||
border-left: 3px solid var(--nav-accent);
|
||||
padding-left: calc(0.55rem - 2px);
|
||||
color: #fff !important;
|
||||
box-shadow: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* SVG line icons in sidebar */
|
||||
.sidebar-nav-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 0;
|
||||
color: var(--nav-accent, #7dd3fc);
|
||||
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 14%, rgba(255,255,255,.06));
|
||||
border: 1px solid color-mix(in srgb, var(--nav-accent, #22d3ee) 35%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
|
||||
}
|
||||
.sidebar-nav-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
.app-sidebar a.nav-pill:hover .sidebar-nav-icon {
|
||||
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 24%, rgba(255,255,255,.08));
|
||||
border-color: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, transparent);
|
||||
}
|
||||
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, #0b1220);
|
||||
border-color: var(--nav-accent, #22d3ee);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--nav-accent, #22d3ee) 45%, transparent);
|
||||
}
|
||||
|
||||
|
||||
.sidebar-nav-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Per-item accent colors */
|
||||
.app-sidebar a.nav-pill-wereldexport { --nav-accent: #0ea5e9; }
|
||||
.app-sidebar a.nav-pill-clients { --nav-accent: #38bdf8; }
|
||||
.app-sidebar a.nav-pill-deals { --nav-accent: #4ade80; }
|
||||
.app-sidebar a.nav-pill-products { --nav-accent: #a855f7; }
|
||||
.app-sidebar a.nav-pill-suppliers { --nav-accent: #fb923c; }
|
||||
.app-sidebar a.nav-pill-chat { --nav-accent: #fcd34d; }
|
||||
.app-sidebar a.nav-pill-reports { --nav-accent: #6366f1; }
|
||||
.app-sidebar a.nav-pill-herman { --nav-accent: #fbbf24; }
|
||||
.app-sidebar a.nav-pill-retail { --nav-accent: #0066cc; }
|
||||
.app-sidebar a.nav-pill-marketing { --nav-accent: #ff6b9d; }
|
||||
.app-sidebar a.nav-pill-beurs { --nav-accent: #b8ff3c; }
|
||||
.app-sidebar a.nav-pill-documents { --nav-accent: #94a3b8; }
|
||||
.app-sidebar a.nav-pill-telegram { --nav-accent: #2aabee; }
|
||||
.app-sidebar a.nav-pill-analytics { --nav-accent: #8b5cf6; }
|
||||
.app-sidebar a.nav-pill-agents { --nav-accent: #00e5ff; }
|
||||
.app-sidebar a.nav-pill-browser { --nav-accent: #14b8a6; }
|
||||
.app-sidebar a.nav-pill-studio { --nav-accent: #ec4899; }
|
||||
.app-sidebar a.nav-pill-voice { --nav-accent: #f97316; }
|
||||
.app-sidebar a.nav-pill-ops { --nav-accent: #22d3ee; }
|
||||
.app-sidebar a.nav-pill-settings { --nav-accent: #e2e8f0; }
|
||||
|
||||
.persona-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
margin-right: 0.45rem;
|
||||
}
|
||||
.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
|
||||
.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
|
||||
|
||||
|
||||
/* sidebar resize v11 */
|
||||
|
||||
.app-sidebar-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
background: transparent;
|
||||
}
|
||||
.app-sidebar-resizer:hover,
|
||||
.app-sidebar-resizer.is-dragging {
|
||||
background: rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
body.sidebar-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
body.sidebar-resizing iframe {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-sidebar-resizer { display: none !important; }
|
||||
.app-main { margin-left: 0 !important; }
|
||||
.app-sidebar {
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
@@ -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,183 @@
|
||||
/* Wereldexport — full-width map workspace */
|
||||
|
||||
.wereldexport-workspace.vtabs-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 0.65rem !important;
|
||||
width: 100%;
|
||||
min-height: calc(100dvh - 220px);
|
||||
}
|
||||
|
||||
.wereldexport-workspace > .vtabs-nav {
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.5rem 0.65rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.wereldexport-workspace > .vtabs-nav::before { display: none; }
|
||||
|
||||
.wereldexport-workspace .vtabs-nav-header { display: none; }
|
||||
|
||||
.wereldexport-workspace > .vtabs-content {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
|
||||
flex: 0 0 auto;
|
||||
min-width: 128px;
|
||||
max-width: 168px;
|
||||
grid-template-columns: auto 1fr;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.wereldexport-workspace .ei-vtab-meta,
|
||||
.wereldexport-workspace .ei-vtab-key,
|
||||
.wereldexport-workspace .ei-vtab-sub { display: none; }
|
||||
|
||||
.wereldexport-workspace .vtab-btn.active {
|
||||
box-shadow: inset 0 -2px 0 var(--tab-accent, #0ea5e9);
|
||||
}
|
||||
|
||||
.ei-tab-panel:has(.ei-workspace-body) {
|
||||
background: rgba(8, 11, 16, 0.95);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100dvh - 280px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ei-tab-panel:has(.ei-workspace-body-map) .ei-tab-panel-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ei-workspace-body,
|
||||
.ei-workspace-body-map {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-legend {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-empty {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0 0.75rem 0.45rem;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-empty-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-empty-inner strong {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-empty-inner p {
|
||||
flex: 1 1 200px;
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-empty-actions {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map .ei-map-truncated,
|
||||
.ei-workspace-body-map .ei-map-error {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.75rem 0.35rem;
|
||||
}
|
||||
|
||||
.ei-workspace-body-map > .ei-split.ei-split-side {
|
||||
flex: 1 1 auto;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.ei-workspace-body-map > .ei-split.ei-split-side {
|
||||
min-height: calc(100dvh - 320px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.wereldexport-workspace > .vtabs-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
|
||||
min-width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
.ei-neo-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.ei-neo-kpi {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 14, 20, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.ei-neo-ring {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ei-neo-ring-inner {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: #0a0e14;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
|
||||
.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
|
||||
|
||||
.ei-sync-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ei-sync-bar .btn { font-size: 0.72rem; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,591 @@
|
||||
/* Foodlinkk UI + Sidebar themes
|
||||
* UI: <html data-theme="..."> localStorage: flk-theme
|
||||
* Sidebar:<html data-sidebar="..."> localStorage: flk-sidebar-theme
|
||||
*/
|
||||
|
||||
/* ========== UI THEMES (1–5 existing) ========== */
|
||||
html[data-theme="ocean"] {
|
||||
--bg-root: #07131f; --bg-surface: #0c1c2e; --bg-elevated: #123049; --bg-hover: #183a58;
|
||||
--border-subtle: #1f4b6e; --border-strong: #2d6a96;
|
||||
--text-primary: #eef9ff; --text-secondary: #b6e3f7; --text-muted: #7eb6d4; --text-body: #d5effc;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
|
||||
--accent-purple: #67e8f9; --accent-purple-dim: rgba(103,232,249,.18);
|
||||
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.2);
|
||||
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 8px 32px rgba(8,47,73,.55); --flk-theme-glow: rgba(34,211,238,.35);
|
||||
}
|
||||
html[data-theme="aurora"] {
|
||||
--bg-root: #0a1020; --bg-surface: #121a32; --bg-elevated: #1a2550; --bg-hover: #243266;
|
||||
--border-subtle: #3347a0; --border-strong: #4f6dff;
|
||||
--text-primary: #f0f4ff; --text-secondary: #c7d2fe; --text-muted: #9db0f5; --text-body: #dde4ff;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
|
||||
--accent-purple: #a78bfa; --accent-purple-dim: rgba(167,139,250,.22);
|
||||
--accent-amber: #818cf8; --accent-amber-dim: rgba(129,140,248,.22);
|
||||
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
|
||||
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
|
||||
--shadow-panel: 0 8px 32px rgba(49,46,129,.5); --flk-theme-glow: rgba(167,139,250,.4);
|
||||
}
|
||||
html[data-theme="neon"] {
|
||||
--bg-root: #050b14; --bg-surface: #0a1628; --bg-elevated: #10243f; --bg-hover: #163255;
|
||||
--border-subtle: #1a4d6e; --border-strong: #06b6d4;
|
||||
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
|
||||
--accent-cyan: #06b6d4; --accent-cyan-dim: rgba(6,182,212,.28);
|
||||
--accent-purple: #e879f9; --accent-purple-dim: rgba(232,121,249,.2);
|
||||
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.25);
|
||||
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
|
||||
--accent-red: #ff6bcb; --accent-red-dim: rgba(255,107,203,.22);
|
||||
--shadow-panel: 0 0 40px rgba(6,182,212,.18); --flk-theme-glow: rgba(6,182,212,.45);
|
||||
}
|
||||
html[data-theme="glacier"] {
|
||||
--bg-root: #0b1522; --bg-surface: #122033; --bg-elevated: #1a2f4a; --bg-hover: #223b5a;
|
||||
--border-subtle: #2a5078; --border-strong: #3b82f6;
|
||||
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
|
||||
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.22);
|
||||
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.2);
|
||||
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.2);
|
||||
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 8px 28px rgba(30,64,175,.35); --flk-theme-glow: rgba(56,189,248,.35);
|
||||
}
|
||||
html[data-theme="cobalt"] {
|
||||
--bg-root: #08101c; --bg-surface: #0f1a2e; --bg-elevated: #172642; --bg-hover: #1f3256;
|
||||
--border-subtle: #274878; --border-strong: #3b82f6;
|
||||
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
|
||||
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.22);
|
||||
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.18);
|
||||
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
|
||||
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
|
||||
--shadow-panel: 0 10px 36px rgba(37,99,235,.28); --flk-theme-glow: rgba(59,130,246,.4);
|
||||
}
|
||||
|
||||
/* ========== UI THEMES (6–15 new) ========== */
|
||||
html[data-theme="reef"] {
|
||||
--bg-root: #04161a; --bg-surface: #0a242b; --bg-elevated: #11353f; --bg-hover: #174650;
|
||||
--border-subtle: #1f6b78; --border-strong: #2dd4bf;
|
||||
--text-primary: #ecfeff; --text-secondary: #99f6e4; --text-muted: #5eead4; --text-body: #ccfbf1;
|
||||
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
|
||||
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
|
||||
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.2);
|
||||
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.2);
|
||||
--shadow-panel: 0 10px 36px rgba(13,148,136,.35); --flk-theme-glow: rgba(45,212,191,.45);
|
||||
}
|
||||
html[data-theme="plasma"] {
|
||||
--bg-root: #0c0618; --bg-surface: #180b2e; --bg-elevated: #26114a; --bg-hover: #321860;
|
||||
--border-subtle: #6d28d9; --border-strong: #a78bfa;
|
||||
--text-primary: #faf5ff; --text-secondary: #e9d5ff; --text-muted: #c4b5fd; --text-body: #f3e8ff;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
|
||||
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.25);
|
||||
--accent-amber: #f0abfc; --accent-amber-dim: rgba(240,171,252,.2);
|
||||
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
|
||||
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
|
||||
--shadow-panel: 0 10px 40px rgba(109,40,217,.4); --flk-theme-glow: rgba(192,132,252,.5);
|
||||
}
|
||||
html[data-theme="lagoon"] {
|
||||
--bg-root: #031a1f; --bg-surface: #072830; --bg-elevated: #0c3a45; --bg-hover: #114c59;
|
||||
--border-subtle: #0e7490; --border-strong: #22d3ee;
|
||||
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.28);
|
||||
--accent-purple: #2dd4bf; --accent-purple-dim: rgba(45,212,191,.22);
|
||||
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.22);
|
||||
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 10px 36px rgba(8,145,178,.35); --flk-theme-glow: rgba(34,211,238,.45);
|
||||
}
|
||||
html[data-theme="indigo"] {
|
||||
--bg-root: #07091a; --bg-surface: #0f1430; --bg-elevated: #1a2250; --bg-hover: #242d68;
|
||||
--border-subtle: #3730a3; --border-strong: #6366f1;
|
||||
--text-primary: #eef2ff; --text-secondary: #c7d2fe; --text-muted: #a5b4fc; --text-body: #e0e7ff;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
|
||||
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.25);
|
||||
--accent-amber: #60a5fa; --accent-amber-dim: rgba(96,165,250,.22);
|
||||
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
|
||||
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
|
||||
--shadow-panel: 0 10px 36px rgba(67,56,202,.4); --flk-theme-glow: rgba(99,102,241,.45);
|
||||
}
|
||||
html[data-theme="skyline"] {
|
||||
--bg-root: #06101c; --bg-surface: #0c1c33; --bg-elevated: #142d4d; --bg-hover: #1b3c64;
|
||||
--border-subtle: #0284c7; --border-strong: #38bdf8;
|
||||
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
|
||||
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.28);
|
||||
--accent-purple: #0ea5e9; --accent-purple-dim: rgba(14,165,233,.22);
|
||||
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.22);
|
||||
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 10px 36px rgba(2,132,199,.35); --flk-theme-glow: rgba(56,189,248,.5);
|
||||
}
|
||||
html[data-theme="mintvolt"] {
|
||||
--bg-root: #04140f; --bg-surface: #0a221a; --bg-elevated: #10352a; --bg-hover: #164738;
|
||||
--border-subtle: #059669; --border-strong: #34d399;
|
||||
--text-primary: #ecfdf5; --text-secondary: #a7f3d0; --text-muted: #6ee7b7; --text-body: #d1fae5;
|
||||
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
|
||||
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
|
||||
--accent-amber: #a3e635; --accent-amber-dim: rgba(163,230,53,.22);
|
||||
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.25);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 10px 36px rgba(5,150,105,.35); --flk-theme-glow: rgba(52,211,153,.45);
|
||||
}
|
||||
html[data-theme="sapphire"] {
|
||||
--bg-root: #050d1f; --bg-surface: #0b1835; --bg-elevated: #122650; --bg-hover: #1a3470;
|
||||
--border-subtle: #1d4ed8; --border-strong: #3b82f6;
|
||||
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
|
||||
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.25);
|
||||
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.22);
|
||||
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.2);
|
||||
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
|
||||
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
|
||||
--shadow-panel: 0 10px 40px rgba(29,78,216,.4); --flk-theme-glow: rgba(59,130,246,.5);
|
||||
}
|
||||
html[data-theme="arctic"] {
|
||||
--bg-root: #0a121c; --bg-surface: #121c2a; --bg-elevated: #1c2a3d; --bg-hover: #26384f;
|
||||
--border-subtle: #64748b; --border-strong: #94a3b8;
|
||||
--text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-muted: #94a3b8; --text-body: #f1f5f9;
|
||||
--accent-cyan: #67e8f9; --accent-cyan-dim: rgba(103,232,249,.25);
|
||||
--accent-purple: #7dd3fc; --accent-purple-dim: rgba(125,211,252,.22);
|
||||
--accent-amber: #bae6fd; --accent-amber-dim: rgba(186,230,253,.2);
|
||||
--accent-green: #5eead4; --accent-green-dim: rgba(94,234,212,.2);
|
||||
--accent-red: #fda4af; --accent-red-dim: rgba(253,164,175,.2);
|
||||
--shadow-panel: 0 10px 32px rgba(100,116,139,.3); --flk-theme-glow: rgba(125,211,252,.4);
|
||||
}
|
||||
html[data-theme="hyperion"] {
|
||||
--bg-root: #041018; --bg-surface: #081c2a; --bg-elevated: #0e2d42; --bg-hover: #143d58;
|
||||
--border-subtle: #0891b2; --border-strong: #22d3ee;
|
||||
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.3);
|
||||
--accent-purple: #a3e635; --accent-purple-dim: rgba(163,230,53,.22);
|
||||
--accent-amber: #4ade80; --accent-amber-dim: rgba(74,222,128,.22);
|
||||
--accent-green: #84cc16; --accent-green-dim: rgba(132,204,22,.22);
|
||||
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
|
||||
--shadow-panel: 0 0 42px rgba(8,145,178,.3); --flk-theme-glow: rgba(34,211,238,.55);
|
||||
}
|
||||
html[data-theme="meridian"] {
|
||||
--bg-root: #0a0716; --bg-surface: #140d28; --bg-elevated: #221645; --bg-hover: #2e1e5c;
|
||||
--border-subtle: #7c3aed; --border-strong: #a78bfa;
|
||||
--text-primary: #f5f3ff; --text-secondary: #ddd6fe; --text-muted: #c4b5fd; --text-body: #ede9fe;
|
||||
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
|
||||
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.28);
|
||||
--accent-amber: #e879f9; --accent-amber-dim: rgba(232,121,249,.22);
|
||||
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
|
||||
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
|
||||
--shadow-panel: 0 10px 40px rgba(124,58,237,.4); --flk-theme-glow: rgba(167,139,250,.5);
|
||||
}
|
||||
|
||||
html[data-theme] body {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent-cyan) 18%, transparent), transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--accent-purple) 14%, transparent), transparent 55%),
|
||||
var(--bg-root);
|
||||
}
|
||||
html[data-theme] .panel,
|
||||
html[data-theme] .hm-neo {
|
||||
border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle));
|
||||
}
|
||||
html[data-theme] .btn-pulse,
|
||||
html[data-theme] .btn-primary {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 40%, transparent),
|
||||
0 8px 24px color-mix(in srgb, var(--accent-cyan) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ========== SIDEBAR THEMES (independent, bright) ========== */
|
||||
html[data-sidebar] .app-sidebar {
|
||||
border-right: 1px solid color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 45%, transparent) !important;
|
||||
box-shadow: inset -1px 0 0 color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 20%, transparent),
|
||||
4px 0 28px color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 12%, transparent);
|
||||
}
|
||||
html[data-sidebar] .app-sidebar a.nav-pill:hover {
|
||||
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 16%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 35%, transparent);
|
||||
}
|
||||
html[data-sidebar] .app-sidebar a.nav-pill.active {
|
||||
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 22%, rgba(8,12,18,.9)) !important;
|
||||
box-shadow: inset 3px 0 0 var(--flk-sidebar-accent, #22d3ee);
|
||||
}
|
||||
html[data-sidebar] .app-sidebar-label {
|
||||
color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 85%, #fff) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
html[data-sidebar="aqua"] .app-sidebar {
|
||||
--flk-sidebar-accent: #5eead4;
|
||||
--flk-sidebar-solid: #0a4f5c;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="azure"] .app-sidebar {
|
||||
--flk-sidebar-accent: #60a5fa;
|
||||
--flk-sidebar-solid: #163a7a;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="turquoise"] .app-sidebar {
|
||||
--flk-sidebar-accent: #2dd4bf;
|
||||
--flk-sidebar-solid: #0a453f;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="cerulean"] .app-sidebar {
|
||||
--flk-sidebar-accent: #38bdf8;
|
||||
--flk-sidebar-solid: #0c4a6e;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="electric"] .app-sidebar {
|
||||
--flk-sidebar-accent: #22d3ee;
|
||||
--flk-sidebar-solid: #0a3d4a;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="violet"] .app-sidebar {
|
||||
--flk-sidebar-accent: #818cf8;
|
||||
--flk-sidebar-solid: #2a1f6b;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="emerald"] .app-sidebar {
|
||||
--flk-sidebar-accent: #34d399;
|
||||
--flk-sidebar-solid: #0a4a38;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="midnight"] .app-sidebar {
|
||||
--flk-sidebar-accent: #7dd3fc;
|
||||
--flk-sidebar-solid: #0d1f3c;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="steel"] .app-sidebar {
|
||||
--flk-sidebar-accent: #94a3b8;
|
||||
--flk-sidebar-solid: #1a2d42;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
html[data-sidebar="nordic"] .app-sidebar {
|
||||
--flk-sidebar-accent: #67e8f9;
|
||||
--flk-sidebar-solid: #0e3a4f;
|
||||
--flk-sidebar-grad: linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%);
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
|
||||
/* Sidebar fill mode: gradient (default) vs plain solid */
|
||||
html[data-sidebar][data-sidebar-style="plain"] .app-sidebar {
|
||||
background: var(--flk-sidebar-solid, #0a3d4a) !important;
|
||||
}
|
||||
html[data-sidebar]:not([data-sidebar-style="plain"]) .app-sidebar {
|
||||
background: var(--flk-sidebar-grad) !important;
|
||||
}
|
||||
|
||||
.flk-style-toggle {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin: 0.65rem 0 0.85rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent);
|
||||
}
|
||||
.flk-style-toggle button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #b6e3f7);
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flk-style-toggle button.is-active {
|
||||
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan, #22d3ee) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Settings cards */
|
||||
.flk-theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.flk-theme-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-elevated);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
transition: border-color .15s, transform .15s, box-shadow .15s;
|
||||
}
|
||||
.flk-theme-card:hover { transform: translateY(-2px); border-color: var(--accent-cyan); }
|
||||
.flk-theme-card.is-active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim), 0 12px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
.flk-theme-swatch {
|
||||
height: 52px; border-radius: 10px; margin-bottom: 0.55rem;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background-color: #1e293b;
|
||||
background-size: cover !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
.flk-theme-card strong { display: block; margin-bottom: 0.12rem; font-size: 0.9rem; }
|
||||
.flk-theme-card small { opacity: 0.75; line-height: 1.3; display: block; font-size: 0.75rem; }
|
||||
.flk-theme-section { margin-top: 1.75rem; }
|
||||
.flk-theme-section h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
|
||||
|
||||
/* ========== PAGE BACKGROUNDS (independent of UI theme) ==========
|
||||
* <html data-bg="..."> localStorage: flk-page-bg
|
||||
* Applies to body + app-main + common subpage shells
|
||||
*/
|
||||
html[data-bg="navy"] { --flk-page-bg: #0d2b5c; --flk-page-bg2: #134078; --bg-root: #0d2b5c; }
|
||||
html[data-bg="royal"] { --flk-page-bg: #1a3a8f; --flk-page-bg2: #274bb8; --bg-root: #1a3a8f; }
|
||||
html[data-bg="azure"] { --flk-page-bg: #0a4f8a; --flk-page-bg2: #0d6cb8; --bg-root: #0a4f8a; }
|
||||
html[data-bg="sky"] { --flk-page-bg: #0e5f9e; --flk-page-bg2: #1880c9; --bg-root: #0e5f9e; }
|
||||
html[data-bg="cerulean"] { --flk-page-bg: #0c6a9e; --flk-page-bg2: #1490c8; --bg-root: #0c6a9e; }
|
||||
html[data-bg="teal"] { --flk-page-bg: #0c5c68; --flk-page-bg2: #128090; --bg-root: #0c5c68; }
|
||||
html[data-bg="lagoon"] { --flk-page-bg: #0a6b7a; --flk-page-bg2: #0f8fa3; --bg-root: #0a6b7a; }
|
||||
html[data-bg="cyan"] { --flk-page-bg: #0a5568; --flk-page-bg2: #0e7a94; --bg-root: #0a5568; }
|
||||
html[data-bg="indigo"] { --flk-page-bg: #2f2a7a; --flk-page-bg2: #4338ca; --bg-root: #2f2a7a; }
|
||||
html[data-bg="violet"] { --flk-page-bg: #4a1d7a; --flk-page-bg2: #6d28d9; --bg-root: #4a1d7a; }
|
||||
html[data-bg="sapphire"] { --flk-page-bg: #123a8c; --flk-page-bg2: #1d4ed8; --bg-root: #123a8c; }
|
||||
html[data-bg="midnight"] { --flk-page-bg: #1a2f55; --flk-page-bg2: #274878; --bg-root: #1a2f55; }
|
||||
html[data-bg="emerald"] { --flk-page-bg: #0d5c45; --flk-page-bg2: #059669; --bg-root: #0d5c45; }
|
||||
html[data-bg="slate"] { --flk-page-bg: #243447; --flk-page-bg2: #334155; --bg-root: #243447; }
|
||||
html[data-bg="graphite"] { --flk-page-bg: #1e2a3a; --flk-page-bg2: #2c3b52; --bg-root: #1e2a3a; }
|
||||
|
||||
html[data-bg] body {
|
||||
background:
|
||||
radial-gradient(ellipse 90% 55% at 12% -8%, color-mix(in srgb, var(--accent-cyan, #22d3ee) 18%, transparent), transparent 55%),
|
||||
radial-gradient(ellipse 70% 45% at 92% 8%, color-mix(in srgb, var(--accent-purple, #818cf8) 14%, transparent), transparent 50%),
|
||||
linear-gradient(165deg, var(--flk-page-bg) 0%, var(--flk-page-bg2, var(--flk-page-bg)) 100%) !important;
|
||||
background-attachment: fixed !important;
|
||||
}
|
||||
html[data-bg] .app-main,
|
||||
html[data-bg] .app-shell,
|
||||
html[data-bg] main,
|
||||
html[data-bg] .page,
|
||||
html[data-bg] .page-wrap,
|
||||
html[data-bg] .content-area,
|
||||
html[data-bg] .settings-layout,
|
||||
html[data-bg] .hm-page,
|
||||
html[data-bg] .warroom-root,
|
||||
html[data-bg] .agents-page {
|
||||
background: transparent !important;
|
||||
}
|
||||
html[data-bg] .panel,
|
||||
html[data-bg] .hm-neo,
|
||||
html[data-bg] .card,
|
||||
html[data-bg] .settings-panel {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Kill leftover near-black shells when page bg is set */
|
||||
html[data-bg] .app-main > *,
|
||||
html[data-bg] .dashboard-root,
|
||||
html[data-bg] .retail-page,
|
||||
html[data-bg] .ops-page,
|
||||
html[data-bg] .documents-page,
|
||||
html[data-bg] .analytics-wrap,
|
||||
html[data-bg] .export-intel,
|
||||
html[data-bg] .clients-page,
|
||||
html[data-bg] .wr-shell,
|
||||
html[data-bg] .voice-page,
|
||||
html[data-bg] .studio-page,
|
||||
html[data-bg] .hermes-page,
|
||||
html[data-bg] .projects-hub,
|
||||
html[data-bg] .packaging-page,
|
||||
html[data-bg] .beurs-page,
|
||||
html[data-bg] .marketing-page,
|
||||
html[data-bg] .revenue-cockpit {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
html[data-bg] {
|
||||
background: var(--flk-page-bg) !important;
|
||||
}
|
||||
|
||||
/* ========== SURFACE OVERRIDE (kill leftover blacks on subpages) ========== */
|
||||
html[data-theme],
|
||||
html[data-bg] {
|
||||
--flk-surface: color-mix(in srgb, var(--bg-elevated, #123049) 88%, #fff 0%);
|
||||
--flk-surface-2: color-mix(in srgb, var(--bg-surface, #0c1c2e) 92%, var(--accent-cyan, #22d3ee) 8%);
|
||||
--flk-input: color-mix(in srgb, var(--bg-root, #07131f) 70%, var(--bg-elevated, #123049) 30%);
|
||||
--flk-panel: color-mix(in srgb, var(--bg-elevated, #123049) 75%, transparent);
|
||||
}
|
||||
|
||||
/* When a page background is chosen, lift surface tokens off near-black */
|
||||
html[data-bg="navy"] { --bg-surface: #134078; --bg-elevated: #1a4f96; --bg-hover: #2460b0; }
|
||||
html[data-bg="royal"] { --bg-surface: #274bb8; --bg-elevated: #3359d4; --bg-hover: #4169e8; }
|
||||
html[data-bg="azure"] { --bg-surface: #0d6cb8; --bg-elevated: #1580d4; --bg-hover: #1e92ea; }
|
||||
html[data-bg="sky"] { --bg-surface: #1880c9; --bg-elevated: #2493de; --bg-hover: #36a4ec; }
|
||||
html[data-bg="cerulean"] { --bg-surface: #1490c8; --bg-elevated: #20a3dc; --bg-hover: #32b4ea; }
|
||||
html[data-bg="teal"] { --bg-surface: #128090; --bg-elevated: #1898a8; --bg-hover: #22aebb; }
|
||||
html[data-bg="lagoon"] { --bg-surface: #0f8fa3; --bg-elevated: #18a4ba; --bg-hover: #24b8ce; }
|
||||
html[data-bg="cyan"] { --bg-surface: #0e7a94; --bg-elevated: #1490ae; --bg-hover: #1ea4c4; }
|
||||
html[data-bg="indigo"] { --bg-surface: #4338ca; --bg-elevated: #5450e0; --bg-hover: #6666f0; }
|
||||
html[data-bg="violet"] { --bg-surface: #6d28d9; --bg-elevated: #7e3aec; --bg-hover: #8f4cf8; }
|
||||
html[data-bg="sapphire"] { --bg-surface: #1d4ed8; --bg-elevated: #2b5fec; --bg-hover: #3b70f8; }
|
||||
html[data-bg="midnight"] { --bg-surface: #274878; --bg-elevated: #335c96; --bg-hover: #4070b0; }
|
||||
html[data-bg="emerald"] { --bg-surface: #059669; --bg-elevated: #0aad78; --bg-hover: #14c08a; }
|
||||
html[data-bg="slate"] { --bg-surface: #334155; --bg-elevated: #3f5068; --bg-hover: #4d6078; }
|
||||
html[data-bg="graphite"] { --bg-surface: #2c3b52; --bg-elevated: #384a66; --bg-hover: #465a78; }
|
||||
|
||||
/* App chrome */
|
||||
html[data-theme] .app-topbar,
|
||||
html[data-bg] .app-topbar,
|
||||
html[data-theme] .app-main,
|
||||
html[data-bg] .app-main {
|
||||
background: transparent !important;
|
||||
}
|
||||
html[data-theme] .app-topbar,
|
||||
html[data-bg] .app-topbar {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 55%, transparent) !important;
|
||||
border-bottom-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Generic panels / cards */
|
||||
html[data-theme] .panel,
|
||||
html[data-bg] .panel,
|
||||
html[data-theme] .card,
|
||||
html[data-bg] .card,
|
||||
html[data-theme] .hm-neo,
|
||||
html[data-bg] .hm-neo,
|
||||
html[data-theme] .settings-panel,
|
||||
html[data-bg] .settings-panel {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 78%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--border-subtle, #1f4b6e)) !important;
|
||||
}
|
||||
|
||||
/* ========== Export Intel surfaces ========== */
|
||||
html[data-theme] .ei-page,
|
||||
html[data-bg] .ei-page {
|
||||
background: transparent !important;
|
||||
}
|
||||
html[data-theme] .ei-kpi,
|
||||
html[data-bg] .ei-kpi,
|
||||
html[data-theme] .ei-neo-kpi,
|
||||
html[data-bg] .ei-neo-kpi {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-neo-ring-inner,
|
||||
html[data-bg] .ei-neo-ring-inner {
|
||||
background: var(--bg-surface, #0c1c2e) !important;
|
||||
}
|
||||
html[data-theme] .ei-filter-bar,
|
||||
html[data-bg] .ei-filter-bar {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--accent-cyan, #22d3ee) 16%, var(--bg-elevated, #123049)),
|
||||
color-mix(in srgb, var(--bg-surface, #0c1c2e) 90%, transparent)
|
||||
) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-filters select,
|
||||
html[data-theme] .ei-filters input,
|
||||
html[data-theme] .ei-filter-label select,
|
||||
html[data-theme] .ei-filter-label input,
|
||||
html[data-bg] .ei-filters select,
|
||||
html[data-bg] .ei-filters input,
|
||||
html[data-bg] .ei-filter-label select,
|
||||
html[data-bg] .ei-filter-label input {
|
||||
background: color-mix(in srgb, var(--bg-root, #07131f) 55%, var(--bg-elevated, #123049) 45%) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
|
||||
color: var(--text-primary, #eef9ff) !important;
|
||||
}
|
||||
html[data-theme] .ei-tab-panel,
|
||||
html[data-bg] .ei-tab-panel,
|
||||
html[data-theme] .ei-tab-panel:has(.ei-workspace-body),
|
||||
html[data-bg] .ei-tab-panel:has(.ei-workspace-body) {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 72%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-side-panel,
|
||||
html[data-bg] .ei-side-panel {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-elevated, #123049) 92%, #fff 0%),
|
||||
color-mix(in srgb, var(--bg-surface, #0c1c2e) 95%, transparent)
|
||||
) !important;
|
||||
border-left-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-side-panel-head,
|
||||
html[data-bg] .ei-side-panel-head {
|
||||
background: color-mix(in srgb, var(--bg-hover, #183a58) 55%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-map-wrap,
|
||||
html[data-bg] .ei-map-wrap {
|
||||
background: color-mix(in srgb, var(--bg-root, #07131f) 80%, var(--bg-elevated, #123049) 20%) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-map-loading,
|
||||
html[data-bg] .ei-map-loading {
|
||||
background: color-mix(in srgb, var(--bg-root, #07131f) 70%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-map-wrap .leaflet-popup-content-wrapper,
|
||||
html[data-theme] .ei-map-wrap .leaflet-popup-tip,
|
||||
html[data-bg] .ei-map-wrap .leaflet-popup-content-wrapper,
|
||||
html[data-bg] .ei-map-wrap .leaflet-popup-tip {
|
||||
background: var(--bg-elevated, #123049) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 40%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-table th,
|
||||
html[data-bg] .ei-table th {
|
||||
background: var(--bg-elevated, #123049) !important;
|
||||
}
|
||||
html[data-theme] .ei-table tbody tr,
|
||||
html[data-bg] .ei-table tbody tr {
|
||||
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent);
|
||||
}
|
||||
html[data-theme] .ei-table tbody tr:nth-child(even),
|
||||
html[data-bg] .ei-table tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 45%, transparent);
|
||||
}
|
||||
html[data-theme] .ei-drawer,
|
||||
html[data-bg] .ei-drawer,
|
||||
html[data-theme] .ei-modal-card,
|
||||
html[data-bg] .ei-modal-card,
|
||||
html[data-theme] .ei-progress-card,
|
||||
html[data-bg] .ei-progress-card {
|
||||
background: var(--bg-elevated, #123049) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-sync-monitor,
|
||||
html[data-bg] .ei-sync-monitor {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 88%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .ei-sync-job,
|
||||
html[data-bg] .ei-sync-job,
|
||||
html[data-theme] .ei-sync-coverage div,
|
||||
html[data-bg] .ei-sync-coverage div,
|
||||
html[data-theme] .ei-google-places,
|
||||
html[data-bg] .ei-google-places {
|
||||
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 70%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .wereldexport-workspace > .vtabs-nav,
|
||||
html[data-bg] .wereldexport-workspace > .vtabs-nav {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent) !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent);
|
||||
}
|
||||
html[data-theme] .vtab-btn,
|
||||
html[data-bg] .vtab-btn {
|
||||
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent) !important;
|
||||
}
|
||||
html[data-theme] .vtab-btn.active,
|
||||
html[data-bg] .vtab-btn.active {
|
||||
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--bg-elevated, #123049)) !important;
|
||||
}
|
||||
|
||||
/* Persona / brand switch in topbar */
|
||||
html[data-theme] .persona-btn,
|
||||
html[data-bg] .persona-btn,
|
||||
html[data-theme] .brand-switch .persona-btn,
|
||||
html[data-bg] .brand-switch .persona-btn {
|
||||
background: color-mix(in srgb, var(--bg-elevated, #123049) 60%, transparent) !important;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* Herman / Hermes chat — always-on floating dock */
|
||||
.ha-dock {
|
||||
/* wrapper only; FAB/panel are fixed to viewport */
|
||||
}
|
||||
.ha-dock-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ha-fab {
|
||||
position: fixed !important;
|
||||
right: max(1.25rem, env(safe-area-inset-right, 0px));
|
||||
bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
|
||||
z-index: 10050 !important;
|
||||
height: 52px;
|
||||
width: auto;
|
||||
min-width: 52px;
|
||||
padding: 0 1.15rem 0 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.65);
|
||||
background: linear-gradient(145deg, #0ea5e9 0%, #0284c7 55%, #0369a1 100%);
|
||||
color: #f8fafc;
|
||||
font-size: 1.15rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(14, 165, 233, 0.18),
|
||||
0 10px 28px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ha-fab-icon { line-height: 1; font-size: 1.2rem; }
|
||||
.ha-fab-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
.ha-fab:hover {
|
||||
transform: translateY(-2px) scale(1.03);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(14, 165, 233, 0.28),
|
||||
0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ha-fab.is-open {
|
||||
background: linear-gradient(145deg, #4c1d95, #1e1b4b);
|
||||
border-color: rgba(167, 139, 250, 0.7);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(167, 139, 250, 0.2),
|
||||
0 10px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ha-fab.is-busy {
|
||||
border-color: #38bdf8;
|
||||
animation: ha-pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.ha-panel {
|
||||
position: fixed !important;
|
||||
right: max(1rem, env(safe-area-inset-right, 0px));
|
||||
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 10049 !important;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
max-height: min(620px, calc(100vh - 8rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
background: rgba(10, 14, 20, 0.97);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ha-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ha-head-title { min-width: 0; }
|
||||
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; display: flex; align-items: center; gap: 0.4rem; }
|
||||
.ha-badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: #ddd6fe;
|
||||
background: rgba(124, 58, 237, 0.35);
|
||||
border: 1px solid rgba(167, 139, 250, 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.45rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ha-conn {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.62rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.ha-conn.is-ok { color: #34d399; }
|
||||
.ha-conn.is-err { color: #f87171; }
|
||||
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; flex-shrink: 0; }
|
||||
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
|
||||
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
|
||||
.ha-text-btn { font-size: 0.68rem !important; color: #94a3b8 !important; padding: 0.2rem 0.35rem !important; }
|
||||
.ha-text-btn:hover { color: #e2e8f0 !important; }
|
||||
.ha-text-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.ha-confirm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-bottom: 1px solid rgba(251, 191, 36, 0.25);
|
||||
font-size: 0.75rem;
|
||||
color: #fde68a;
|
||||
}
|
||||
.ha-confirm-actions { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
|
||||
|
||||
.ha-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 220px;
|
||||
max-height: 360px;
|
||||
}
|
||||
.ha-bubble {
|
||||
max-width: 92%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ha-bubble-user {
|
||||
align-self: flex-end;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border: 1px solid rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
.ha-bubble-agent {
|
||||
align-self: flex-start;
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
.ha-bubble-meta { font-size: 0.62rem; color: #64748b; margin-bottom: 0.15rem; }
|
||||
.ha-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.ha-preview {
|
||||
margin: 0 0.65rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
border: 1px dashed rgba(167, 139, 250, 0.3);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.ha-preview.is-interim { font-style: italic; }
|
||||
|
||||
.ha-input-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
|
||||
.ha-mic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(167, 139, 250, 0.45);
|
||||
background: rgba(15, 20, 25, 0.95);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ha-mic.is-listening { border-color: #f87171; animation: ha-pulse 1s infinite; }
|
||||
.ha-mic.is-busy { border-color: #38bdf8; }
|
||||
@keyframes ha-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); }
|
||||
}
|
||||
|
||||
.ha-status { font-size: 0.72rem; color: #cbd5e1; padding: 0.25rem 0.65rem 0.4rem; line-height: 1.35; }
|
||||
.ha-status.is-error { color: #fecaca; background: rgba(127, 29, 29, 0.35); border-radius: 6px; margin: 0 0.5rem 0.35rem; padding: 0.4rem 0.55rem; }
|
||||
.ha-secure-hint {
|
||||
margin: 0.25rem 0.55rem 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ha-secure-hint strong { color: #f8fafc; display: block; margin-bottom: 0.25rem; }
|
||||
.ha-secure-hint a { display: inline-block; margin-top: 0.4rem; }
|
||||
|
||||
.ha-results-modal { z-index: 1600; }
|
||||
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
|
||||
|
||||
.ei-modal-root {
|
||||
position: fixed; inset: 0; z-index: 1600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem; background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ei-modal-card {
|
||||
background: #0f1419; border-radius: 14px; padding: 1.25rem;
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
max-width: 96vw; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
|
||||
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
.voice-results-table th, .voice-results-table td { padding: 0.4rem 0.55rem; border-top: 1px solid rgba(148,163,184,0.1); font-size: 0.78rem; }
|
||||
.voice-results-table a { color: #38bdf8; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ha-fab {
|
||||
right: 1rem;
|
||||
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); /* above mobile bottom nav */
|
||||
}
|
||||
.ha-panel {
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.ha-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 0.9rem;
|
||||
padding: 0.15rem 0.65rem 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ha-toggles label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ha-toggles input {
|
||||
accent-color: #38bdf8;
|
||||
margin: 0;
|
||||
}
|
||||
.ha-mic.is-listening {
|
||||
box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
/* Toasts boven de Herman FAB, niet erachter */
|
||||
.toast-container {
|
||||
bottom: 5.75rem !important;
|
||||
right: 1rem !important;
|
||||
left: auto !important;
|
||||
z-index: 10120 !important;
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
}
|
||||
.toast {
|
||||
color: #f1f5f9 !important;
|
||||
background: #0f172a !important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
|
||||
}
|
||||
.toast-error { border-color: #f87171 !important; color: #fecaca !important; }
|
||||
|
||||
.ha-https-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0.55rem 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ha-https-prompt .btn { flex-shrink: 0; }
|
||||
.ha-https-prompt .ha-text-btn { margin-left: auto; color: #94a3b8; }
|
||||
|
||||
.ha-fab.has-custom-pos {
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
}
|
||||
.ha-fab.is-dragging {
|
||||
cursor: grabbing !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.ha-fab {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ha-panel.has-custom-pos {
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
}
|
||||
@@ -1,6 +1,174 @@
|
||||
/* Herman dashboard — Hermes neo style (KPI cards animate, grote panelen vast) */
|
||||
|
||||
.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); }
|
||||
.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); --hm-radius: 16px; --hm-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); }
|
||||
|
||||
.herman-shell.hm-neo { scroll-behavior: auto; }
|
||||
|
||||
.hm-hero-pro {
|
||||
padding: 1.35rem 1.5rem 1.1rem;
|
||||
box-shadow: var(--hm-shadow);
|
||||
}
|
||||
.hm-hero-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.hm-hero-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hm-hero-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(0, 229, 255, 0.18);
|
||||
font-size: 0.72rem;
|
||||
color: #cbd5e1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-live-badge {
|
||||
font-size: 0.62rem;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||
color: #86efac;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hm-command-bar {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
z-index: 30;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--hm-radius);
|
||||
background: rgba(8, 12, 18, 0.92);
|
||||
border: 1px solid var(--hm-border);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.briefing-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hm-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.hm-card-head h4 { margin: 0; }
|
||||
.hm-card-link {
|
||||
font-size: 0.68rem;
|
||||
color: #38bdf8;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hm-card-link:hover { color: #7dd3fc; }
|
||||
|
||||
.hm-activity-card { border: 1px solid rgba(148, 163, 184, 0.12); }
|
||||
.hm-activity-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hm-activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr;
|
||||
gap: 0.65rem;
|
||||
align-items: start;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.hm-activity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.35rem;
|
||||
background: var(--hm-cyan);
|
||||
box-shadow: 0 0 8px rgba(0, 229, 255, 0.45);
|
||||
}
|
||||
.hm-activity-text {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
color: #dbeafe;
|
||||
}
|
||||
.hm-activity-empty {
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.hm-live-dock {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
z-index: 120;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(8, 12, 18, 0.97);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55), 0 0 24px rgba(0, 229, 255, 0.1);
|
||||
backdrop-filter: blur(14px);
|
||||
animation: hmDockIn 0.28s ease-out;
|
||||
}
|
||||
@keyframes hmDockIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.hm-live-dock-active { border-color: var(--hm-cyan); }
|
||||
.hm-live-dock-done { border-color: rgba(34, 197, 94, 0.45); }
|
||||
.hm-live-dock-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.hm-live-log-compact {
|
||||
max-height: min(36vh, 280px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 229, 255, 0.35) transparent;
|
||||
}
|
||||
.hm-live-log-compact::-webkit-scrollbar { width: 6px; }
|
||||
.hm-live-log-compact::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 229, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hm-live-dock {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
width: auto;
|
||||
}
|
||||
.hm-command-bar { position: relative; top: auto; }
|
||||
}
|
||||
|
||||
.herman-shell.hm-neo .herman-hero {
|
||||
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
|
||||
@@ -34,14 +202,91 @@
|
||||
@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } }
|
||||
|
||||
.briefing-summary-grid {
|
||||
display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem;
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;
|
||||
}
|
||||
.briefing-card.summary { grid-column: 1 / -1; }
|
||||
.briefing-horizon-col { display: grid; gap: 1rem; }
|
||||
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.live-digest-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .live-digest-body { grid-template-columns: 1fr; } }
|
||||
|
||||
.live-digest-section {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.live-digest-section.brand-foodlinkk { border-left: 3px solid #fbbf24; }
|
||||
.live-digest-section.brand-cucina { border-left: 3px solid #fb923c; }
|
||||
.live-digest-section.brand-platform { border-left: 3px solid var(--hm-cyan); }
|
||||
.live-digest-section.brand-news { border-left: 3px solid #a855f7; }
|
||||
|
||||
.live-digest-section h5 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
.live-digest-section.brand-foodlinkk h5 { color: #fbbf24; }
|
||||
.live-digest-section.brand-cucina h5 { color: #fdba74; }
|
||||
.live-digest-section.brand-platform h5 { color: var(--hm-cyan); }
|
||||
.live-digest-section.brand-news h5 { color: #c4b5fd; }
|
||||
|
||||
.live-digest-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.live-digest-list li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: #e2e8f0;
|
||||
padding-left: 0.65rem;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.live-digest-list li.is-typing::after {
|
||||
content: '▋';
|
||||
display: inline-block;
|
||||
margin-left: 1px;
|
||||
color: var(--hm-cyan);
|
||||
animation: twCursorBlink 0.85s step-end infinite;
|
||||
font-weight: 400;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.live-digest-body.is-typing .live-digest-section {
|
||||
animation: twSectionIn 0.35s ease-out both;
|
||||
}
|
||||
@keyframes twCursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@keyframes twSectionIn {
|
||||
from { opacity: 0.4; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.horizon-card ul li.is-typing::after {
|
||||
content: '▋';
|
||||
color: #ff9f43;
|
||||
animation: twCursorBlink 0.85s step-end infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.briefing-card.summary,
|
||||
.horizon-card {
|
||||
background: var(--hm-panel); border-radius: 14px; padding: 1rem;
|
||||
.horizon-card,
|
||||
.briefing-card.hm-activity-card {
|
||||
background: var(--hm-panel); border-radius: var(--hm-radius); padding: 1rem 1.05rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.briefing-card.summary { border: 1px solid var(--hm-border); }
|
||||
.briefing-card.summary h4,
|
||||
@@ -163,7 +408,8 @@
|
||||
|
||||
.hm-chart-panel {
|
||||
background: var(--hm-panel); border: 1px solid var(--hm-border);
|
||||
border-radius: 14px; padding: 1rem 1.1rem; min-height: 260px;
|
||||
border-radius: var(--hm-radius); padding: 1rem 1.1rem; min-height: 260px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.hm-chart-head {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;
|
||||
@@ -202,16 +448,7 @@
|
||||
.page-viz-panel.viz-mode-alt .viz-eq-row { font-size: 0.82rem; padding: 0.15rem 0; }
|
||||
|
||||
.hm-live-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(8, 12, 18, 0.97);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 20px rgba(0, 229, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
}
|
||||
.hm-live-bar-active { border-color: var(--hm-cyan); animation: hmLivePulse 2s ease-in-out infinite; }
|
||||
.hm-live-bar-done { border-color: rgba(34, 197, 94, 0.45); }
|
||||
@@ -435,9 +672,17 @@
|
||||
.app-topbar .page-tip { display: none; }
|
||||
}
|
||||
|
||||
.hm-highlight-scroll { max-height: 220px; overflow-y: auto; }
|
||||
.hm-highlight-scroll,
|
||||
.hm-highlight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.hm-highlight-item {
|
||||
padding: 0.55rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.hm-highlight-item strong { color: #e2e8f0; font-size: 0.85rem; text-decoration: none; }
|
||||
.hm-highlight-item a { color: inherit; text-decoration: none; }
|
||||
@@ -558,3 +803,589 @@
|
||||
display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem;
|
||||
}
|
||||
.agent-soul-editor .agent-editor-header h2 { margin: 0; }
|
||||
|
||||
/* Herman dashboard tabs */
|
||||
.herman-dash-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.88);
|
||||
border: 1px solid rgba(0, 229, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.herman-dash-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.herman-dash-tab:hover { color: #e2e8f0; background: rgba(0, 229, 255, 0.06); }
|
||||
.herman-dash-tab.active {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.1));
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 0 18px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
.herman-dash-tab-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.hm-rss-teaser {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
.hm-rss-teaser a { color: #38bdf8; }
|
||||
|
||||
/* RSS hub tab */
|
||||
.herman-rss-hub { margin-top: 0.25rem; }
|
||||
.herman-rss-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.herman-rss-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
|
||||
.herman-rss-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.herman-rss-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 720px) { .herman-rss-filters { grid-template-columns: 1fr; } }
|
||||
.herman-rss-count { font-size: 0.72rem; color: #94a3b8; white-space: nowrap; }
|
||||
.herman-rss-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 0.9fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1000px) { .herman-rss-grid { grid-template-columns: 1fr; } }
|
||||
.hm-rss-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.herman-rss-card { margin: 0; }
|
||||
.herman-rss-side-item {
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.herman-rss-side-item a { color: #e2e8f0; text-decoration: none; display: block; margin-bottom: 0.15rem; }
|
||||
.herman-rss-side-item a:hover { color: #7dd3fc; }
|
||||
.herman-rss-side-item small { color: #64748b; font-size: 0.65rem; }
|
||||
.herman-rss-col-side .hm-chart-panel { margin-bottom: 0.75rem; }
|
||||
.herman-rss-col-side h4 { margin: 0 0 0.5rem; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
|
||||
/* CEO clean layout — briefing first, less noise */
|
||||
.hm-ceo-clean .hm-briefing-hero {
|
||||
order: -1;
|
||||
}
|
||||
.hm-briefing-clean {
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.hm-briefing-clean .briefing-card.summary {
|
||||
padding: 1.35rem 1.5rem;
|
||||
}
|
||||
.hm-briefing-clean .live-digest-body {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.hm-briefing-clean .live-digest-section {
|
||||
margin-bottom: 1.1rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.hm-briefing-clean .live-digest-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-section-title {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hm-ceo-clean .beurs-mini-chip {
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-eq { display: none; }
|
||||
@media (max-width: 960px) {
|
||||
.hm-briefing-clean { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Retail & markt removed from CEO overview — RSS lives in RSS tab */
|
||||
.hm-ceo-clean [data-widget="retail"] { display: none !important; }
|
||||
|
||||
/* CEO overview: only dagmemo, no horizon/activity */
|
||||
.hm-ceo-clean .briefing-horizon-col,
|
||||
.hm-ceo-clean .horizon-card,
|
||||
.hm-ceo-clean .hm-activity-timeline { display: none !important; }
|
||||
.hm-ceo-clean .hm-briefing-solo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hm-ceo-clean .hm-briefing-solo .briefing-card.summary {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Force CEO overview order (prefs/JS cannot override) */
|
||||
.hm-ceo-clean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hm-ceo-clean > [data-widget="kpis"] { order: 1; }
|
||||
.hm-ceo-clean > [data-widget="executive"] { order: 2; }
|
||||
.hm-ceo-clean > [data-widget="analytics"] { order: 3; }
|
||||
.hm-ceo-clean > [data-widget="briefing"] { order: 4; }
|
||||
.hm-ceo-clean > [data-widget="news"] { order: 5; }
|
||||
.hm-ceo-clean > [data-widget="feed"] { order: 6; }
|
||||
.hm-ceo-clean > [data-widget="approvals"] { order: 7; }
|
||||
.hm-ceo-clean > [data-widget="retail"] { order: 99; display: none !important; }
|
||||
|
||||
/* ═══ CEO PRO LAYOUT ═══ */
|
||||
.hm-ceo-pro {
|
||||
gap: 1.15rem;
|
||||
}
|
||||
.hm-ceo-pro .hm-dash-section {
|
||||
margin: 0;
|
||||
}
|
||||
.hm-ceo-pro .hm-section-title { display: block; }
|
||||
|
||||
/* KPI row — bold hierarchy */
|
||||
.hm-ceo-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.hm-ceo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hm-ceo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.hm-ceo-kpi-link { text-decoration: none; color: inherit; display: block; }
|
||||
.hm-ceo-kpi {
|
||||
background: linear-gradient(165deg, rgba(18, 28, 42, 0.95), rgba(8, 12, 20, 0.98));
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-top: 2px solid var(--kpi-accent, #00e5ff);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.05rem 0.9rem;
|
||||
min-height: 92px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.hm-ceo-kpi-link:hover .hm-ceo-kpi {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hm-ceo-kpi-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8b9cb3;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.hm-ceo-kpi-value {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hm-ceo-kpi-sub {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* B2B | B2C */
|
||||
.hm-ceo-brand-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hm-ceo-brand-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hm-ceo-brand-card {
|
||||
background: rgba(10, 16, 26, 0.92);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
padding: 1.1rem 1.2rem 1rem;
|
||||
}
|
||||
.hm-ceo-brand-card.brand-foodlinkk { border-left: 3px solid #38bdf8; }
|
||||
.hm-ceo-brand-card.brand-cucina { border-left: 3px solid #fb923c; }
|
||||
.hm-ceo-brand-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hm-ceo-brand-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.hm-ceo-brand-icon { font-size: 1.15rem; }
|
||||
.hm-ceo-brand-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hm-ceo-brand-list li {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
color: #cbd5e1;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid rgba(148,163,184,0.2);
|
||||
}
|
||||
|
||||
/* Focus + Trends */
|
||||
.hm-ceo-mid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hm-ceo-mid-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hm-ceo-panel {
|
||||
background: rgba(10, 16, 26, 0.92);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
.hm-ceo-panel header h3 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.hm-ceo-focus { border-top: 2px solid #f472b6; }
|
||||
.hm-ceo-trends { border-top: 2px solid #34d399; }
|
||||
.hm-ceo-action-list,
|
||||
.hm-ceo-trend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.hm-ceo-action-list li,
|
||||
.hm-ceo-trend-list li {
|
||||
font-size: 0.88rem;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hm-ceo-bullet {
|
||||
color: #f472b6;
|
||||
font-weight: 700;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.hm-ceo-trend-list li {
|
||||
color: #a7f3d0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Herman memo — collapsed by default */
|
||||
.hm-ceo-memo {
|
||||
margin-top: 0.9rem;
|
||||
background: rgba(10, 16, 26, 0.7);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-memo summary {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
list-style: none;
|
||||
}
|
||||
.hm-ceo-memo summary::-webkit-details-marker { display: none; }
|
||||
.hm-ceo-memo[open] summary { margin-bottom: 0.65rem; color: #cbd5e1; }
|
||||
.hm-ceo-memo-body {
|
||||
font-size: 0.86rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.hm-ceo-memo-body .live-digest-body {
|
||||
display: block !important;
|
||||
grid-template-columns: none !important;
|
||||
}
|
||||
|
||||
/* News collapsible */
|
||||
.hm-ceo-news-details {
|
||||
background: rgba(10, 16, 26, 0.85);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 1rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-news-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
color: #e2e8f0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.hm-ceo-news-details summary::-webkit-details-marker { display: none; }
|
||||
.hm-ceo-news-details summary small {
|
||||
font-weight: 400;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.hm-ceo-news-details summary a { color: #38bdf8; }
|
||||
.hm-ceo-news-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hm-ceo-news-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.22);
|
||||
}
|
||||
.hm-ceo-news-list a {
|
||||
color: #e2e8f0;
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.hm-ceo-news-list a:hover { color: #7dd3fc; }
|
||||
.hm-ceo-news-list small { color: #64748b; font-size: 0.7rem; }
|
||||
|
||||
/* Hide noisy chrome on CEO pro */
|
||||
.hm-ceo-pro ~ .herman-browser,
|
||||
.herman-shell:has(.hm-ceo-pro) .hm-eq { display: none; }
|
||||
|
||||
.hm-ceo-memo-open {
|
||||
margin-top: 0.9rem;
|
||||
background: rgba(10, 16, 26, 0.85);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
.hm-ceo-memo-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
/* Pipeline HTML bars — geen Chart.js ghosting */
|
||||
.hm-pipeline-host { min-height: 140px; }
|
||||
.hm-pipeline-bars { padding: 0.25rem 0.15rem 0.5rem; }
|
||||
.hm-pipeline-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: 0.85rem; gap: 0.75rem;
|
||||
}
|
||||
.hm-pipeline-head strong { color: #f1f5f9; font-size: 1.05rem; }
|
||||
.hm-pipeline-head span { color: #94a3b8; font-size: 0.82rem; }
|
||||
.hm-pipeline-list { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.hm-pipeline-meta {
|
||||
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||
margin-bottom: 0.3rem; font-size: 0.82rem;
|
||||
}
|
||||
.hm-pipeline-stage { color: #e2e8f0; font-weight: 600; text-transform: capitalize; }
|
||||
.hm-pipeline-vals { color: #94a3b8; }
|
||||
.hm-pipeline-track {
|
||||
height: 10px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.06); overflow: hidden;
|
||||
}
|
||||
.hm-pipeline-fill {
|
||||
height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||
}
|
||||
.hm-pipeline-foot { margin: 0.85rem 0 0; font-size: 0.78rem; }
|
||||
.hm-pipeline-foot a { color: #38bdf8; text-decoration: none; }
|
||||
|
||||
/* CEO context-rich overview */
|
||||
.hm-overview-panel { border-color: rgba(0,229,255,0.14); }
|
||||
.hm-context-banner {
|
||||
background: linear-gradient(120deg, rgba(14, 116, 144, 0.18), rgba(15, 23, 42, 0.4));
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.hm-context-banner p {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hm-exec-grid-rich {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hm-exec-grid-rich { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.hm-exec-rich {
|
||||
background: rgba(8, 12, 20, 0.65);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.hm-exec-rich:hover { border-color: rgba(0,229,255,0.35); transform: translateY(-1px); }
|
||||
.hm-exec-rich .hm-exec-val { font-size: 1.2rem; font-weight: 700; color: #f8fafc; margin-top: 0.15rem; }
|
||||
.hm-exec-sub { font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
|
||||
.hm-context-block { margin-top: 0.95rem; }
|
||||
.hm-context-block h4 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.hm-deal-chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.hm-deal-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
background: rgba(0,0,0,0.28);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
text-decoration: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.hm-deal-chip span { color: #86efac; font-weight: 650; }
|
||||
.hm-deal-chip em { color: #94a3b8; font-style: normal; font-size: 0.7rem; }
|
||||
.hm-deal-chip-client span { color: #7dd3fc; }
|
||||
.hm-context-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.hm-context-list li { font-size: 0.84rem; color: #cbd5e1; }
|
||||
.hm-score {
|
||||
display: inline-block; margin-left: 0.25rem;
|
||||
font-size: 0.68rem; color: #fbbf24;
|
||||
background: rgba(251,191,36,0.12);
|
||||
border-radius: 4px; padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.hm-ceo-brand-card { min-height: 160px; }
|
||||
.hm-ceo-pro .hm-briefing-hero, .hm-ceo-pro .hm-ceo-mid { margin-top: 0.15rem; }
|
||||
|
||||
|
||||
/* v32-kpi-six — 6 kerncijfers */
|
||||
.hm-neo-kpi-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.hm-neo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.hm-neo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
/* v33: CEO pipeline compact — geen lege scroll-ruimte */
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-analytics-panel,
|
||||
.hm-ceo-pro .hm-dash-analytics .hm-analytics-panel {
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-host,
|
||||
.hm-ceo-pro .hm-pipeline-host,
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-chart-wrap,
|
||||
.hm-ceo-pro .hm-dash-analytics .hm-chart-wrap {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.hm-ceo-clean .hm-neo-charts,
|
||||
.hm-ceo-pro .hm-neo-charts {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-dash-analytics .hm-section-title {
|
||||
display: none; /* panel head is genoeg — geen dubbele Pipeline-titel */
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-bars {
|
||||
padding: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-list {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-pipeline-foot {
|
||||
margin: 0.55rem 0 0;
|
||||
}
|
||||
|
||||
/* Briefing + nieuws compacter */
|
||||
.hm-ceo-clean .hm-ceo-mid-grid {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-panel {
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo {
|
||||
margin-top: 0.65rem;
|
||||
max-height: none;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo-body {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-memo:not([open]) .hm-ceo-memo-body {
|
||||
display: none;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-news-details {
|
||||
margin: 0;
|
||||
}
|
||||
.hm-ceo-clean .hm-ceo-action-list,
|
||||
.hm-ceo-clean .hm-ceo-trend-list {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Minder verticale gap tussen secties */
|
||||
.hm-ceo-pro {
|
||||
gap: 0.85rem !important;
|
||||
}
|
||||
.hm-ceo-clean .hm-dash-section {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@@ -216,3 +216,162 @@
|
||||
.dot-in { background: var(--hm-cyan); }
|
||||
.dot-out { background: var(--hm-purple); }
|
||||
.dot-edge { background: var(--hm-orange); }
|
||||
|
||||
|
||||
.hm-section-title {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
}
|
||||
.hm-bucket-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hm-bucket-card {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
|
||||
.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; }
|
||||
.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
|
||||
.hm-tool-list li {
|
||||
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||
padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
font-size: 0.78rem; color: #cbd5e1;
|
||||
}
|
||||
.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; }
|
||||
.hm-tool-list-tall { max-height: 280px; overflow: auto; }
|
||||
.status-badge.on { color: #b8ff3c; }
|
||||
.status-badge.off { color: #f87171; }
|
||||
|
||||
/* Clickable buckets + explorer modal */
|
||||
.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; }
|
||||
.hm-bucket-click {
|
||||
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||
transition: border-color .15s, transform .12s, background .15s;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.hm-bucket-click:hover {
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hm-bucket-open {
|
||||
margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75;
|
||||
}
|
||||
.hm-bucket-click:hover .hm-bucket-open { opacity: 1; }
|
||||
|
||||
.hm-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(2, 6, 14, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.hm-modal {
|
||||
width: min(1100px, 96vw);
|
||||
height: min(820px, 92vh);
|
||||
background: #0b1220;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.55);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-modal-head {
|
||||
display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start;
|
||||
padding: 1rem 1.1rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
|
||||
.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.hm-modal-close {
|
||||
background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem;
|
||||
}
|
||||
.hm-modal-close:hover { color: #fff; }
|
||||
.hm-chip {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: rgba(15,23,42,0.7);
|
||||
color: #cbd5e1; border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer;
|
||||
}
|
||||
.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); }
|
||||
|
||||
.hm-modal-tabs {
|
||||
display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
}
|
||||
.hm-modal-tabs button {
|
||||
background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem;
|
||||
font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; }
|
||||
|
||||
.hm-modal-body { flex: 1; min-height: 0; }
|
||||
.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; }
|
||||
.hm-modal-side {
|
||||
border-right: 1px solid rgba(148,163,184,0.12);
|
||||
overflow: auto; background: rgba(8, 12, 20, 0.9);
|
||||
position: relative;
|
||||
}
|
||||
.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; }
|
||||
.hm-side-loading {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8;
|
||||
background: rgba(14,165,233,0.08);
|
||||
}
|
||||
.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; }
|
||||
.hm-event-item, .hm-file-item {
|
||||
width: 100%; text-align: left; cursor: pointer;
|
||||
background: transparent; border: 1px solid transparent; border-radius: 8px;
|
||||
padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem;
|
||||
}
|
||||
.hm-event-item:hover, .hm-file-item:hover,
|
||||
.hm-event-item.active, .hm-file-item.active {
|
||||
background: rgba(14,165,233,0.1);
|
||||
border-color: rgba(56,189,248,0.25);
|
||||
}
|
||||
.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||
.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; }
|
||||
.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; }
|
||||
.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; }
|
||||
.hm-event-preview, .hm-file-path {
|
||||
font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.hm-file-name { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; }
|
||||
.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; }
|
||||
.hm-explorer-bar {
|
||||
display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;
|
||||
padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
position: sticky; top: 0; background: #0b1220; z-index: 1;
|
||||
}
|
||||
.hm-explorer-path {
|
||||
font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
|
||||
}
|
||||
.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
|
||||
.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; }
|
||||
.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; }
|
||||
.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; }
|
||||
.hm-preview-code {
|
||||
margin: 0; padding: 0.85rem; border-radius: 10px;
|
||||
background: #020617; border: 1px solid rgba(148,163,184,0.15);
|
||||
color: #e2e8f0; font-size: 0.72rem; line-height: 1.45;
|
||||
white-space: pre-wrap; word-break: break-word; max-height: none;
|
||||
}
|
||||
.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; }
|
||||
.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
|
||||
.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hm-modal { height: 94vh; }
|
||||
.hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; }
|
||||
.hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
:root {
|
||||
--bg0: #06090e;
|
||||
--bg1: #0c121b;
|
||||
--card: #111821;
|
||||
--line: rgba(148, 163, 184, 0.18);
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--gold: #fbbf24;
|
||||
--green: #22c55e;
|
||||
--danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
.login-body {
|
||||
min-height: 100%;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(900px 420px at 15% -10%, rgba(251, 191, 36, 0.14), transparent 55%),
|
||||
radial-gradient(700px 380px at 100% 10%, rgba(34, 197, 94, 0.1), transparent 50%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1) 55%, #0a1018);
|
||||
}
|
||||
.login-shell {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: max(1.25rem, env(safe-area-inset-top)) 1rem max(1.25rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
background: rgba(17, 24, 33, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 1.75rem 1.5rem 1.35rem;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.login-logo {
|
||||
display: block;
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
.login-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.login-error {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.login-form { display: grid; gap: 0.85rem; }
|
||||
.login-label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
.login-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(251, 191, 36, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
.login-btn {
|
||||
margin-top: 0.35rem;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-btn:hover { filter: brightness(1.05); }
|
||||
.login-foot {
|
||||
margin: 1.1rem 0 0;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
+324
-24
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Foodlinkk Command Center — mobile (Android + iOS)
|
||||
* Must load LAST so it wins over app-sidebar-neo / herman-dashboard.
|
||||
*/
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
@@ -32,8 +33,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 998;
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 999;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -68,16 +70,6 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
||||
}
|
||||
.hm-exec-grid { grid-template-columns: 1fr !important; }
|
||||
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
|
||||
.vtabs-layout { grid-template-columns: 1fr !important; }
|
||||
.vtabs-nav {
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: relative !important;
|
||||
}
|
||||
.vtabs-nav::before { display: none; }
|
||||
.vtab-btn { flex: 0 0 auto; white-space: nowrap; }
|
||||
.retail-workspace, .retail-workspace.with-filters {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
@@ -97,31 +89,59 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
||||
.regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; }
|
||||
.regs-columns { flex-direction: column !important; display: flex !important; }
|
||||
.pwa-install-bar { display: flex; }
|
||||
|
||||
/* Scherper op tablet/mobiel: minder soft blur */
|
||||
.hm-panel, .hm-chart-panel, .live-digest, .briefing-card,
|
||||
.ha-panel, .panel {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn { display: inline-flex; }
|
||||
.mobile-menu-btn { display: inline-flex !important; }
|
||||
.mobile-nav-overlay { display: block; }
|
||||
body:not(.nav-open) .mobile-nav-overlay { display: none; }
|
||||
body:not(.nav-open) .mobile-nav-overlay { display: none !important; }
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr !important;
|
||||
display: block !important;
|
||||
}
|
||||
.app-main {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
/* Hard overrides — must beat app-sidebar-neo.css */
|
||||
.app-sidebar {
|
||||
position: fixed !important;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: min(280px, 88vw);
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: min(280px, 88vw) !important;
|
||||
height: 100dvh !important;
|
||||
z-index: 999;
|
||||
transform: translateX(-105%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000 !important;
|
||||
transform: translateX(-105%) !important;
|
||||
transition: transform 0.3s ease !important;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
padding-top: max(1rem, var(--safe-top));
|
||||
padding-top: max(1rem, var(--safe-top)) !important;
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
body.nav-open .app-sidebar { transform: translateX(0); }
|
||||
.app-sidebar-resizer { display: none !important; }
|
||||
.persona-nav {
|
||||
flex: none;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
body.nav-open .app-sidebar { transform: translateX(0) !important; }
|
||||
body.nav-open { overflow: hidden; }
|
||||
|
||||
.app-topbar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
padding-top: max(0.5rem, var(--safe-top));
|
||||
@@ -131,16 +151,296 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
||||
.retail-actions .btn, .herman-briefing-actions .btn {
|
||||
min-width: 100%; flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.herman-briefing-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.herman-briefing-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.page-header h1 { font-size: 1.2rem !important; }
|
||||
}
|
||||
|
||||
.pwa-install-bar {
|
||||
display: none;
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 10100;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-bottom: max(0.75rem, var(--safe-bottom));
|
||||
background: rgba(8, 12, 18, 0.95);
|
||||
background: rgba(8, 12, 18, 0.97);
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.pwa-install-bar p { margin: 0; font-size: 0.85rem; color: #cbd5e1; flex: 1; }
|
||||
@media (display-mode: standalone) { .pwa-install-bar { display: none !important; } }
|
||||
.pwa-install-hint {
|
||||
display: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pwa-install-bar.is-ios .pwa-install-hint.is-ios-hint { display: block; }
|
||||
.pwa-install-bar.is-android .pwa-install-hint.is-android-hint { display: block; }
|
||||
.pwa-install-bar:not(.is-ios):not(.is-android) .pwa-install-hint.is-android-hint { display: block; }
|
||||
@media (display-mode: standalone), (display-mode: fullscreen) {
|
||||
.pwa-install-bar { display: none !important; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* ===== War Room FULL CHAT on phone (Android + iOS) ===== */
|
||||
@media (max-width: 900px) {
|
||||
/* Tabs: compact chip row */
|
||||
.vtabs-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 0.3rem !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.vtabs-nav {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
z-index: 50 !important;
|
||||
padding: 0.3rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.vtabs-nav .vtab-btn {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
white-space: nowrap !important;
|
||||
min-height: 40px !important;
|
||||
padding: 0.35rem 0.6rem !important;
|
||||
}
|
||||
.vtabs-nav-header { display: none !important; }
|
||||
.vtabs-content { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; min-height: 0 !important; }
|
||||
|
||||
/* Hide bulky page chrome in War Room */
|
||||
.hm-neo.tab-warroom-active > .wr-page-header,
|
||||
.hm-neo.tab-warroom-active > .page-header {
|
||||
display: none !important;
|
||||
}
|
||||
.hm-neo.tab-warroom-active {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
|
||||
max-height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
|
||||
min-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.hm-neo.tab-warroom-active .vtabs-layout {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.hm-neo.tab-warroom-active .vtabs-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* Let Alpine x-show hide inactive tabs; only stretch the visible warroom section */
|
||||
.hm-neo.tab-warroom-active .vtabs-content > section:not([style*="display: none"]) {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.wr-hub-root {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.wr-hub-root > .panel {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
padding: 0.35rem !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.wr-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
gap: 0.3rem !important;
|
||||
}
|
||||
|
||||
.wr-mobile-bar {
|
||||
display: flex !important;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wr-layout.roster-collapsed .wr-roster { display: none !important; }
|
||||
.wr-roster {
|
||||
flex: 0 0 auto !important;
|
||||
max-height: 18vh !important;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.15) !important;
|
||||
padding: 0 0 0.25rem !important;
|
||||
}
|
||||
.wr-roster h3 { display: none !important; }
|
||||
.wr-room-btn, .wr-agent-btn {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.wr-main {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Compact toolbar */
|
||||
.wr-head {
|
||||
flex: 0 0 auto !important;
|
||||
margin: 0 !important;
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
.wr-head-title h2 { font-size: 0.9rem !important; }
|
||||
.wr-head-title .muted,
|
||||
.wr-head-title .wr-msg-count { display: none !important; }
|
||||
.wr-head-actions {
|
||||
width: 100%;
|
||||
gap: 0.25rem !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.wr-head-actions .btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 36px !important;
|
||||
padding: 0.3rem 0.45rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
.wr-head-actions .wr-a2a-btn,
|
||||
.wr-head-actions button[title="Open op tweede scherm"],
|
||||
.wr-head-actions button[title="Spraakmeeting met agents"],
|
||||
.wr-head-actions button[title="Snel praten"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* THE chat window — take all remaining space */
|
||||
.wr-log {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 50dvh !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Readable bubbles */
|
||||
.wr-bubble { gap: 0.4rem !important; margin-bottom: 0.55rem !important; }
|
||||
.wr-avatar { width: 34px !important; height: 34px !important; flex-basis: 34px !important; }
|
||||
.wr-avatar img, .wr-avatar .agent-char-wrap,
|
||||
.wr-avatar .agent-char-wrap svg,
|
||||
.wr-avatar-fallback { width: 34px !important; height: 34px !important; }
|
||||
.wr-bubble-body {
|
||||
max-width: calc(100% - 42px) !important;
|
||||
width: auto !important;
|
||||
padding: 0.45rem 0.55rem !important;
|
||||
}
|
||||
.wr-bubble-text {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1.45 !important;
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
.wr-bubble-meta { font-size: 0.72rem !important; }
|
||||
.wr-msg-reply, .wr-msg-del { opacity: 0.8 !important; }
|
||||
|
||||
.wr-composer {
|
||||
flex: 0 0 auto !important;
|
||||
position: relative !important;
|
||||
bottom: auto !important;
|
||||
z-index: 20;
|
||||
margin-top: 0.25rem !important;
|
||||
padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)) !important;
|
||||
background: rgba(8, 12, 18, 0.98);
|
||||
}
|
||||
.wr-composer textarea,
|
||||
.wr-composer .form-input {
|
||||
font-size: 16px !important;
|
||||
min-height: 48px !important;
|
||||
line-height: 1.35 !important;
|
||||
}
|
||||
.wr-composer .btn-pulse-green {
|
||||
min-height: 48px !important;
|
||||
min-width: 4.5rem !important;
|
||||
}
|
||||
.wr-ceo-panel {
|
||||
width: 100% !important;
|
||||
max-height: 85dvh !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Less top padding around agents content on phone */
|
||||
@media (max-width: 900px) {
|
||||
.app-main .main-topnav {
|
||||
padding-top: 0.45rem !important;
|
||||
padding-bottom: 0.45rem !important;
|
||||
}
|
||||
body:has(.tab-warroom-active) .app-main .main-topnav .page-tip { display: none !important; }
|
||||
}
|
||||
|
||||
@supports not (height: 100dvh) {
|
||||
@media (max-width: 900px) {
|
||||
.hm-neo.tab-warroom-active {
|
||||
height: calc(100vh - 3.25rem) !important;
|
||||
max-height: calc(100vh - 3.25rem) !important;
|
||||
}
|
||||
.wr-log { min-height: 50vh !important; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* reply-target mobile */
|
||||
@media (max-width: 900px) {
|
||||
.wr-bubble.is-reply-target .wr-bubble-body {
|
||||
outline: 2px solid #38bdf8;
|
||||
}
|
||||
.wr-msg-del { opacity: 0.35 !important; }
|
||||
.wr-msg-reply { opacity: 1 !important; font-weight: 600; }
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ body {
|
||||
background: var(--bg-root);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -25,6 +26,32 @@ a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Themed scrollbars (Firefox + WebKit) */
|
||||
html {
|
||||
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(8, 12, 18, 0.65);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(8, 12, 18, 0.55);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.45);
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(8, 12, 18, 0.55);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -129,7 +156,7 @@ a:hover {
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: 1.0625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
@@ -369,7 +396,7 @@ a:hover {
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
@@ -382,7 +409,7 @@ a:hover {
|
||||
.data-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.8125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -458,7 +485,7 @@ a:hover {
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; }
|
||||
.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
|
||||
.btn-sm { padding: .25rem .55rem; font-size: 12px; }
|
||||
.btn-sm { padding: 0.35rem 0.75rem; font-size: 0.875rem; min-height: 2rem; }
|
||||
.clickable-row { cursor: pointer; transition: background .15s; }
|
||||
.clickable-row:hover { background: var(--bg-hover); }
|
||||
.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; }
|
||||
@@ -485,7 +512,7 @@ a:hover {
|
||||
.chat-msg.user { background: rgba(0,200,255,.08); }
|
||||
.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); }
|
||||
.chat-input { display: flex; gap: .5rem; }
|
||||
.page-tip { color: var(--text-muted); font-size: 13px; }
|
||||
.page-tip { color: var(--text-muted); font-size: 0.9375rem; line-height: 1.5; }
|
||||
.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; }
|
||||
.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; }
|
||||
body.drawer-open { overflow: hidden; }
|
||||
@@ -648,7 +675,7 @@ body.drawer-open { overflow: hidden; }
|
||||
}
|
||||
@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; }
|
||||
.form-label { display: block; font-size: 0.875rem; color: var(--text-muted); margin-bottom: 0.75rem; }
|
||||
.form-label .form-input { margin-top: 0.25rem; }
|
||||
.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; }
|
||||
|
||||
@@ -865,3 +892,121 @@ body:has(.topbar) .main { margin-left: 0; }
|
||||
border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
|
||||
}
|
||||
.novnc-fallback a { color: var(--accent-cyan); }
|
||||
|
||||
/* ── AI / LLM settings ── */
|
||||
.llm-hero-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(8, 12, 18, 0.95));
|
||||
}
|
||||
.llm-hero-body { flex: 1 1 280px; min-width: 0; }
|
||||
.llm-hero-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.45rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #7dd3fc;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
}
|
||||
.llm-hero-card h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.llm-hero-lead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
color: #94a3b8;
|
||||
max-width: 42rem;
|
||||
}
|
||||
.llm-hero-lead code {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #bae6fd;
|
||||
}
|
||||
.llm-hero-steps {
|
||||
margin: 0.65rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.llm-hero-steps a { color: #38bdf8; }
|
||||
|
||||
.llm-section-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.llm-section-sub {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.llm-preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.llm-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-height: 7.5rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.12s;
|
||||
}
|
||||
.llm-preset-card:hover {
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.llm-preset-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.llm-preset-hint {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
flex: 1;
|
||||
}
|
||||
.llm-preset-models {
|
||||
font-size: 0.68rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #475569;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.llm-provider-list { margin-top: 0.25rem; }
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/* Parse Intelligence Workbench */
|
||||
.pi-workbench {
|
||||
--pi-accent: #00e5ff;
|
||||
--pi-gold: #ffd700;
|
||||
--pi-purple: #a855f7;
|
||||
--pi-bg: rgba(6, 10, 18, 0.92);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.12);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pi-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.pi-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
background: linear-gradient(90deg, #f1f5f9, #00e5ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
|
||||
.pi-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.pi-kpi-cell {
|
||||
padding: 0.85rem 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.pi-kpi-cell:last-child { border-right: none; }
|
||||
.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
|
||||
.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
|
||||
.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
|
||||
|
||||
.pi-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 380px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.pi-body { grid-template-columns: 240px 1fr; }
|
||||
.pi-detail { display: none; }
|
||||
.pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
|
||||
}
|
||||
|
||||
.pi-filters {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
max-height: 720px;
|
||||
}
|
||||
|
||||
.pi-filters label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin: 0.85rem 0 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-filters label:first-child { margin-top: 0; }
|
||||
|
||||
.pi-search {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-search:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
|
||||
}
|
||||
|
||||
.pi-select, .pi-range {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pi-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
margin: 0.45rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-toggle-row input { accent-color: #00e5ff; }
|
||||
|
||||
.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
|
||||
|
||||
.pi-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-filter-chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pi-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pi-trends-bar {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(168, 85, 247, 0.04);
|
||||
}
|
||||
|
||||
.pi-trends-bar .lbl {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
|
||||
.pi-hype-chip {
|
||||
font-size: 0.76rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 85, 247, 0.35);
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #e9d5ff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
|
||||
.pi-hype-chip.active {
|
||||
border-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
color: #a5f3fc;
|
||||
box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
|
||||
}
|
||||
|
||||
.pi-hype-chip.cross {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
|
||||
|
||||
.pi-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.pi-toolbar .count {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pi-toolbar .count strong { color: #00e5ff; }
|
||||
|
||||
.pi-sort-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-sort-btn.active {
|
||||
border-color: rgba(0, 229, 255, 0.45);
|
||||
color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
|
||||
|
||||
.pi-view-toggle button {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
|
||||
|
||||
.pi-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pi-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(10, 15, 25, 0.98);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
color: #64748b;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pi-table th:hover { color: #00e5ff; }
|
||||
|
||||
.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
|
||||
|
||||
.pi-table td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
vertical-align: middle;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-table tr { cursor: pointer; transition: background 0.12s; }
|
||||
|
||||
.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.pi-table tbody tr.selected {
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
box-shadow: inset 3px 0 0 #00e5ff;
|
||||
}
|
||||
|
||||
.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
|
||||
.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
|
||||
.pi-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
|
||||
|
||||
.pi-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-y: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
|
||||
.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
|
||||
|
||||
.pi-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pi-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-detail-head {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
|
||||
|
||||
.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
|
||||
|
||||
.pi-detail-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pi-detail-tabs button.active {
|
||||
color: #00e5ff;
|
||||
border-bottom-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.05);
|
||||
}
|
||||
|
||||
.pi-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.pi-read-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pi-read-text mark {
|
||||
background: rgba(255, 215, 0, 0.35);
|
||||
color: #fef9c3;
|
||||
padding: 0 0.15rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pi-link-list { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.pi-link-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pi-link-list a { color: #86efac; word-break: break-all; }
|
||||
|
||||
.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.pi-heading-item {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
border-left: 3px solid rgba(0, 229, 255, 0.4);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
/* Topbar identity chip */
|
||||
.app-user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-left: 0.65rem;
|
||||
padding: 0.2rem 0.35rem 0.2rem 0.2rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.app-user-chip input[type="file"],
|
||||
.user-avatar-input {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
border: 0 !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.user-avatar-btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar-btn:focus-visible {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.65);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.user-avatar-img,
|
||||
.user-avatar-fallback {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(251, 191, 36, 0.55);
|
||||
background: linear-gradient(145deg, #1e293b, #0f172a);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.user-avatar-img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-avatar-fallback {
|
||||
display: none; /* Alpine x-show zet dit aan indien geen foto */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fbbf24;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Als Alpine display:flex/inline-flex zet via x-show */
|
||||
.user-avatar-fallback[style*="flex"] {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.user-avatar-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding-right: 0.15rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.user-avatar-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-avatar-role {
|
||||
font-size: 0.65rem;
|
||||
color: #94a3b8;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.user-logout-btn {
|
||||
min-height: 30px !important;
|
||||
padding: 0.2rem 0.65rem !important;
|
||||
font-size: 0.7rem !important;
|
||||
border-radius: 999px !important;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.user-avatar-meta { display: none; }
|
||||
.app-user-chip {
|
||||
padding: 0.15rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings list */
|
||||
.user-card-avatar,
|
||||
.user-form-avatar-preview {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50% !important;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(251, 191, 36, 0.4);
|
||||
background: #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-card-avatar.fallback,
|
||||
.user-form-avatar-preview.fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
}
|
||||
.user-form-avatar-preview {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.user-form-avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user