SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC

This commit is contained in:
sysops
2026-06-23 10:04:23 +00:00
parent 3bf15c4850
commit 26fe76afdd
165 changed files with 47427 additions and 1264 deletions
+21
View File
@@ -0,0 +1,21 @@
-- Docling conversion results for NAS documents
CREATE TABLE IF NOT EXISTS docling_results (
id SERIAL PRIMARY KEY,
storage_path TEXT NOT NULL,
file_sig VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'pending',
options JSONB NOT NULL DEFAULT '{}'::jsonb,
exports JSONB NOT NULL DEFAULT '{}'::jsonb,
tables_data JSONB NOT NULL DEFAULT '[]'::jsonb,
pictures JSONB NOT NULL DEFAULT '[]'::jsonb,
page_count INT DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (storage_path, file_sig)
);
CREATE INDEX IF NOT EXISTS idx_docling_results_path ON docling_results (storage_path);
CREATE INDEX IF NOT EXISTS idx_docling_results_status ON docling_results (status, created_at DESC);
+10
View File
@@ -0,0 +1,10 @@
-- Editable workspace drafts for Docling / document editor
CREATE TABLE IF NOT EXISTS document_workspace (
storage_path TEXT PRIMARY KEY,
source_format VARCHAR(32) DEFAULT 'markdown',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_document_workspace_updated ON document_workspace (updated_at DESC);
+30
View File
@@ -0,0 +1,30 @@
-- Document ↔ klant/project koppelingen + auto-ingest log
CREATE TABLE IF NOT EXISTS document_links (
id SERIAL PRIMARY KEY,
storage_path TEXT NOT NULL,
is_folder BOOLEAN NOT NULL DEFAULT FALSE,
client_id INT REFERENCES clients(id) ON DELETE CASCADE,
project_id INT REFERENCES cockpit_projects(id) ON DELETE SET NULL,
link_type VARCHAR(32) NOT NULL DEFAULT 'nas_share',
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(64) DEFAULT 'cockpit'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_links_unique
ON document_links (storage_path, COALESCE(client_id, 0), COALESCE(project_id, 0));
CREATE INDEX IF NOT EXISTS idx_document_links_client ON document_links (client_id);
CREATE INDEX IF NOT EXISTS idx_document_links_project ON document_links (project_id);
CREATE INDEX IF NOT EXISTS idx_document_links_path ON document_links (storage_path);
CREATE TABLE IF NOT EXISTS ingest_automation_log (
id SERIAL PRIMARY KEY,
source VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ok',
details JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ingest_automation_log_at ON ingest_automation_log (created_at DESC);
+2265
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
"""Automatische NAS + second-brain synchronisatie."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Any
import httpx
from app.db import execute, fetch_all, fetch_one
log = logging.getLogger("cockpit.auto_ingest")
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
try:
execute(
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
(source, status, json.dumps(details)),
)
except Exception as exc:
log.warning("ingest log failed: %s", exc)
def _brain_chat_id_for_client(client_id: int) -> int:
return -int(client_id)
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
rows = fetch_all(
"""
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
da.word_count, dl.client_id, c.name AS client_name
FROM document_analytics da
LEFT JOIN document_links dl ON (
da.storage_path = dl.storage_path
OR da.storage_path LIKE dl.storage_path || '/%'
) AND dl.is_folder = TRUE
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
WHERE da.storage_path IS NOT NULL
ORDER BY da.analyzed_at DESC NULLS LAST
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
synced = 0
errors = 0
async with httpx.AsyncClient(timeout=120.0) as client:
for row in rows:
path = row.get("storage_path") or ""
if not path:
continue
client_id = row.get("client_id")
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
text = (
f"NAS document: {row.get('filename') or path}\n"
f"Pad: {path}\nType: {row.get('doc_type')}\n"
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
)
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
try:
r = await client.post(
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
json={
"chat_id": chat_id,
"direction": "inbound",
"role": "system",
"content_type": "document",
"content_text": text[:4000],
"agent_name": "auto-ingest",
"embed": True,
"chat_type": "client_brain" if client_id else "nas_corpus",
"user_name": row.get("client_name") or "NAS",
"content_json": {"storage_path": path, "client_id": client_id},
"telegram_message_id": msg_hash,
},
)
if r.status_code < 400:
synced += 1
else:
errors += 1
except Exception as exc:
log.warning("brain sync %s: %s", path, exc)
errors += 1
return {"synced": synced, "errors": errors, "candidates": len(rows)}
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
out: dict[str, Any] = {"ok": True, "steps": {}}
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
params={"force": "true" if force_scan else "false"},
)
r.raise_for_status()
out["steps"]["nas_scan"] = r.json()
except Exception as exc:
out["steps"]["nas_scan"] = {"error": str(exc)}
brain = await sync_nas_to_brain()
out["steps"]["brain_sync"] = brain
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
_log_run("auto_sync", status, out)
out["last_logged"] = True
return out
def last_auto_sync() -> dict[str, Any] | None:
row = fetch_one(
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
)
if not row:
return None
d = dict(row)
if hasattr(d.get("created_at"), "isoformat"):
d["created_at"] = d["created_at"].isoformat()
if isinstance(d.get("details"), str):
try:
d["details"] = json.loads(d["details"])
except Exception:
pass
return d
+9 -1
View File
@@ -560,6 +560,7 @@ def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]:
SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview,
jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count,
jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at,
session_id, created_at
FROM photo_imports ORDER BY created_at DESC LIMIT %s
""",
@@ -589,7 +590,11 @@ def photos_image(photo_id: int) -> Response:
@app.get("/photos/{photo_id}/detections")
def photos_detections(photo_id: int) -> dict[str, Any]:
row = fetch_one(
"SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s",
"""
SELECT id, detections, extracted_items, ocr_text,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
FROM photo_imports WHERE id = %s
""",
(photo_id,),
)
if not row:
@@ -600,6 +605,9 @@ def photos_detections(photo_id: int) -> dict[str, Any]:
"detections": row.get("detections") or [],
"extracted_items": row.get("extracted_items") or [],
"ocr_text": row.get("ocr_text") or "",
"user_labels": row.get("user_labels") or [],
"label_notes": row.get("label_notes") or "",
"labeled_at": row.get("labeled_at"),
}
+147
View File
@@ -0,0 +1,147 @@
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
from __future__ import annotations
from typing import Any
from app.db import fetch_all, fetch_one
from app.services import projects as projects_svc
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _serialize_rows(rows: list) -> list[dict]:
return [_serialize_row(r) for r in rows if r]
def get_client_360(client_id: int) -> dict[str, Any]:
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
if not client:
return {"ok": False, "error": "Client not found"}
projs = projects_svc.list_projects(client_id=client_id, limit=50)
links = fetch_all(
"""
SELECT dl.*, p.name AS project_name
FROM document_links dl
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
WHERE dl.client_id = %s
ORDER BY dl.created_at DESC
""",
(client_id,),
)
paths = [l["storage_path"] for l in links if l.get("storage_path")]
docs: list[dict] = []
if paths:
placeholders = ",".join(["%s"] * len(paths))
docs = fetch_all(
f"""
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
sentiment_compound, analyzed_at, user_labels
FROM document_analytics
WHERE storage_path IN ({placeholders})
OR storage_path LIKE ANY (
SELECT dl.storage_path || '/%%' FROM document_links dl
WHERE dl.client_id = %s AND dl.is_folder = TRUE
)
ORDER BY analyzed_at DESC NULLS LAST
LIMIT 80
""",
tuple(paths + [client_id]),
)
sentiment_rows = fetch_all(
"""
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
FROM document_analytics da
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY sentiment_label
""",
(client_id,),
)
top_words = fetch_all(
"""
SELECT dwc.lemma, SUM(dwc.count) AS total
FROM document_word_counts dwc
JOIN document_analytics da ON da.id = dwc.document_id
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
AND NOT dwc.is_stopword
GROUP BY dwc.lemma
ORDER BY total DESC
LIMIT 25
""",
(client_id,),
)
deals = fetch_all(
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
(client_id,),
)
stores = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
FROM client_supermarket_links l
JOIN supermarkets s ON s.id = l.supermarket_id
WHERE l.client_id = %s
LIMIT 30
""",
(client_id,),
)
trends = fetch_all(
"""
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
COUNT(*) AS docs,
AVG(da.sentiment_compound) AS avg_sentiment,
SUM(da.word_count) AS words
FROM document_analytics da
WHERE da.analyzed_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12
""",
(client_id,),
)
for t in trends:
if hasattr(t.get("month"), "isoformat"):
t["month"] = t["month"].isoformat()
return {
"ok": True,
"client": _serialize_row(client),
"projects": projs,
"links": _serialize_rows(links),
"documents": _serialize_rows(docs),
"sentiment_breakdown": _serialize_rows(sentiment_rows),
"top_words": _serialize_rows(top_words),
"deals": _serialize_rows(deals),
"stores": _serialize_rows(stores),
"monthly_trends": trends,
"stats": {
"linked_paths": len(links),
"documents": len(docs),
"projects": len(projs),
"stores": len(stores),
"deals": len(deals),
},
}
+4
View File
@@ -10,6 +10,10 @@ class Settings:
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
+21 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import Response
from app.db import close_pool, fetch_all, init_pool
from app.routes import (
@@ -31,7 +32,10 @@ from app.routes import (
packaging,
ops,
ops_api,
revenue_cockpit,
export_intel,
)
from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes.agents_api import router as agents_api_router
@@ -41,8 +45,21 @@ from app.routes.projects_api import router as projects_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class NoCacheStaticFiles(StaticFiles):
"""Serve static assets without browser/SW long-lived caching."""
async def get_response(self, path: str, scope) -> Response:
response = await super().get_response(path, scope)
if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
@@ -66,6 +83,9 @@ for r in (
hermes.router,
packaging.router,
ops.router,
revenue_cockpit.router,
export_intel.router,
revenue_cockpit_api,
api.router,
ops_api.router,
admin_router,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh", "approvals"}:
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
active_tab = "souls"
events: list = []
try:
+355 -34
View File
@@ -1,13 +1,18 @@
"""Agents API — souls & activity."""
from __future__ import annotations
import asyncio
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.db import fetch_all
from app.services import agent_approvals, agent_souls
from app.db import fetch_all, fetch_one
from app.services import agent_approvals, agent_integration, agent_souls
from app.services.agent_names import normalize_agent_key
from app.services.agent_terminal import execute_terminal_command
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
class HandoffBody(BaseModel):
from_agent: str
to_agent: str
handoff_type: str = "partner"
payload: dict[str, Any] = Field(default_factory=dict)
correlation_id: Optional[str] = None
class TerminalCommandBody(BaseModel):
command: str = Field(..., min_length=1, max_length=2000)
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
from datetime import datetime, timedelta, timezone
rows = fetch_all(
"""
SELECT agent_name, title, status, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
"""
)
now = datetime.now(timezone.utc)
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
key = normalize_agent_key(str(r.get("agent_name") or ""))
if not key:
continue
bucket = by_key.setdefault(
key,
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
)
created = r.get("created_at")
if bucket["last_event_at"] is None and created is not None:
bucket["last_event_at"] = created
bucket["last_event_title"] = r.get("title")
if created is not None:
if hasattr(created, "tzinfo") and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created >= now - timedelta(hours=6):
bucket["events_6h"] += 1
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
bucket["errors_24h"] += 1
return by_key
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
if events_6h > 0 and errors_24h == 0:
return "healthy"
if events_6h > 0:
return "warn"
if event_count > 0:
return "idle"
return "offline"
@router.get("/collaboration")
def api_collaboration() -> dict[str, Any]:
items = agent_integration.list_collaboration()
return {"items": items, "count": len(items)}
@router.post("/handoff")
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
try:
handoff = agent_integration.create_handoff(
body.from_agent,
body.to_agent,
handoff_type=body.handoff_type,
payload=body.payload,
correlation_id=body.correlation_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "handoff": handoff}
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
ev["created_at"] = ev["created_at"].isoformat()
meta = ev.get("metadata")
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
ev["metadata"] = meta or {}
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
if str(ev.get("event_type") or "").startswith("terminal_"):
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
if ev["metadata"].get("target_agent"):
line_type = "handoff_out"
if ev["metadata"].get("source_agent"):
line_type = "handoff_in"
if str(ev.get("status") or "") in ("error", "rejected"):
line_type = "error"
return {
"type": line_type,
"id": ev.get("id"),
"agent": normalize_agent_key(ev.get("agent_name")),
"message": ev.get("title") or ev.get("event_type"),
"detail": (ev.get("body") or "")[:500],
"status": ev.get("status"),
"correlation_id": ev["metadata"].get("correlation_id"),
"at": ev.get("created_at"),
}
@router.get("/souls/{agent_key}/terminal/stream")
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
key = normalize_agent_key(agent_key)
soul = agent_souls.get_soul(key)
if not soul:
raise HTTPException(404, "Agent not found")
async def event_gen():
last_id = 0
try:
hist = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s
ORDER BY id DESC
LIMIT 30
""",
(key,),
)
for row in reversed(hist):
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
while True:
try:
if correlation_id:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE metadata->>'correlation_id' = %s
AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(correlation_id, last_id),
)
else:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(key, last_id),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.4)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/live/stream")
async def api_agents_live_stream():
"""Single realtime stream for all agent terminals."""
async def event_gen():
last_id = 0
try:
recent = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '30 minutes'
ORDER BY id ASC
LIMIT 80
"""
)
for row in recent:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
payload["replay"] = True
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
while True:
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE id > %s
ORDER BY id ASC
LIMIT 100
""",
(last_id,),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.25)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
@router.post("/souls/{agent_key}/terminal/command")
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
try:
return await execute_terminal_command(agent_key, body.command.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
stats_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS agent_key,
MAX(created_at) AS last_event_at,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
COUNT(*) FILTER (
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND status IN ('error', 'rejected')
) AS errors_24h
FROM agent_events
GROUP BY LOWER(agent_name)
"""
)
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
by_key = _aggregate_agent_stats()
nodes: list[dict[str, Any]] = []
for soul in souls:
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
health = "offline"
if events_6h > 0 and errors_24h == 0:
health = "healthy"
elif events_6h > 0:
health = "warn"
elif int(soul.get("event_count") or 0) > 0:
health = "idle"
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
is_active = health == "healthy" and events_6h > 0
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
node["last_event_at"] = row["last_event_at"].isoformat()
node["is_active"] = is_active
node["last_event_title"] = row.get("last_event_title")
lat = row.get("last_event_at")
if lat is not None and hasattr(lat, "isoformat"):
node["last_event_at"] = lat.isoformat()
nodes.append(node)
edge_rows = fetch_all(
report_edges: list[dict[str, Any]] = []
for node in nodes:
key = str(node.get("agent_key") or "").lower()
if key == "herman" or not node.get("is_active"):
continue
report_edges.append(
{
"source": key,
"target": "herman",
"type": "report",
"active": True,
"weight": int(node.get("events_6h") or 1),
}
)
delegate_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
SELECT metadata, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND LOWER(agent_name) <> 'herman'
GROUP BY LOWER(agent_name)
ORDER BY weight DESC
WHERE agent_name = 'herman'
AND created_at >= NOW() - INTERVAL '6 hours'
AND (
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
OR metadata ? 'delegated'
)
ORDER BY created_at DESC
LIMIT 100
"""
)
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
delegate_edges: list[dict[str, Any]] = []
seen_delegate: set[tuple[str, str]] = set()
for r in delegate_rows:
meta = r.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
if isinstance(delegated, str):
delegated = [delegated]
channel = meta.get("channel") or "herman"
for agent in delegated:
tgt = normalize_agent_key(str(agent))
if not tgt or tgt == "herman":
continue
pair = ("herman", tgt)
if pair in seen_delegate:
continue
seen_delegate.add(pair)
delegate_edges.append(
{
"source": "herman",
"target": tgt,
"type": "delegate",
"active": True,
"channel": channel,
}
)
peer_static = [
{
"source": str(r["from_agent"]),
"target": str(r["to_agent"]),
"type": "static",
"handoff_type": r.get("handoff_type"),
}
for r in agent_integration.list_collaboration()
]
peer_live = agent_integration.peer_edges_live(hours=6)
peer_edges = peer_static + peer_live
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
executive_edges = []
if herman_active:
executive_edges = [
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
{"source": "herman", "target": "cto", "type": "executive", "active": True},
]
return {
"nodes": nodes,
"report_edges": report_edges,
"delegate_edges": delegate_edges,
"peer_edges": peer_edges,
"executive_edges": executive_edges,
"edges": report_edges,
"executives": [
{"id": "ceo", "label": "CEO", "role": "Aissa"},
{"id": "cto", "label": "CTO", "role": "Platform"},
],
}
@router.get("/approvals")
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
auto = None
rid = (req or {}).get("id")
if rid:
try:
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
except Exception:
auto = None
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
if auto:
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
@router.post("/approvals/{request_id}/approve")
+59 -1
View File
@@ -145,7 +145,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 +158,64 @@ async def dashboard(request: Request):
)
@router.get("/cto")
async def cto_redirect():
return RedirectResponse(url="/ops", status_code=302)
@router.get("/cto/dashboard")
async def cto_dashboard_legacy(request: Request):
kpis = {
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"browser_sessions_24h": 0,
}
try:
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
except Exception:
pass
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at
FROM browser_sessions ORDER BY created_at DESC LIMIT 8
"""
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
return templates.TemplateResponse(
"cto-dashboard.html",
{
"request": request,
"page_title": "CTO Dashboard",
"kpis": kpis,
"agent_feed": agent_feed,
"browser_sessions": browser_sessions,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+2 -1
View File
@@ -73,7 +73,8 @@ async def documents_page(request: Request):
SELECT filename, storage_path, doc_type, language, word_count,
unique_lemmas, sentiment_label, sentiment_compound,
sentiment_positive, sentiment_negative, sentiment_neutral,
extraction_method, analyzed_at
extraction_method, analyzed_at,
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT 50
+209
View File
@@ -0,0 +1,209 @@
"""Export Intel Cockpit page + API proxy."""
from __future__ import annotations
import os
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Query, Request
from fastapi.responses import RedirectResponse, 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("/")
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.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,
entity_type: Optional[str] = None,
has_email: 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 entity_type:
params["entity_type"] = entity_type
if has_email is not None:
params["has_email"] = has_email
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, entity_type: Optional[str] = None):
params = {k: v for k, v in {"country": country, "entity_type": entity_type}.items() if 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/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}
return await _proxy("GET", "/map/bundle", params=params)
@router.post("/api/export-intel/entities/favorites")
async def api_set_favorites(request: Request):
body = await request.json()
return await _proxy("POST", "/entities/favorites", json=body)
@router.get("/api/export-intel/entities/favorites")
async def api_list_favorites(country: Optional[str] = None, region: Optional[str] = None, limit: int = 200):
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/entities/favorites", params=params)
@router.post("/api/export-intel/crm/push")
async def api_crm_push(request: Request):
body = await request.json()
return await _proxy("POST", "/crm/push", json=body)
@router.get("/api/export-intel/crm/pipeline")
async def api_crm_pipeline(country: Optional[str] = None, region: Optional[str] = None, limit: int = 100):
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/crm/pipeline", params=params)
@router.get("/api/export-intel/halal/markets")
async def api_halal_markets(
region: Optional[str] = None,
country: Optional[str] = None,
limit: int = 30,
):
params = {k: v for k, v in {"region": region, "country": country, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/halal/markets", params=params)
@router.get("/api/export-intel/tenders")
async def api_tenders(country: Optional[str] = None, status: Optional[str] = "open", limit: int = 100):
params = {k: v for k, v in {"country": country, "status": status, "limit": limit}.items() if v is not None}
return await _proxy("GET", "/tenders", params=params)
@router.post("/api/export-intel/sync/{kind}")
async def api_sync(kind: str, request: Request):
body = {}
if request.headers.get("content-type", "").startswith("application/json"):
try:
body = await request.json()
except Exception:
body = {}
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region"):
return {"error": "unknown sync kind"}
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
async with httpx.AsyncClient(timeout=timeout) as client:
url = f"{TOOLS}/export-intel/sync/{kind}"
r = await client.post(url, json=body)
return r.json()
+3 -3
View File
@@ -22,7 +22,7 @@ async def herman_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -38,7 +38,7 @@ async def herman_chat(request: Request, message: str = Form(...)):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -62,7 +62,7 @@ async def herman_briefing_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
+230
View File
@@ -0,0 +1,230 @@
"""Revenue Cockpit — page + API."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services import revenue_cockpit as svc
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
router = APIRouter(tags=["revenue-cockpit"])
api = APIRouter(prefix="/api/revenue-cockpit", tags=["revenue-cockpit-api"])
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
margin_month: Optional[float] = None
margin_year: Optional[float] = None
target_revenue: Optional[float] = None
next_steps: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
row_style: Optional[str] = None
class GoalsUpdateBody(BaseModel):
vision_text: Optional[str] = None
horizon_text: Optional[str] = None
mid_text: Optional[str] = None
tagline: Optional[str] = None
class ObjectiveBody(BaseModel):
title: str = Field(..., min_length=1)
description: Optional[str] = None
priority: str = "normal"
due_date: Optional[str] = None
class ObjectiveUpdateBody(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[str] = None
class AssignTaskBody(BaseModel):
agent_name: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
description: Optional[str] = None
objective_id: Optional[int] = None
priority: str = "normal"
delegate_herman: bool = True
class DelegateBody(BaseModel):
message: str = Field(..., min_length=1)
class ImportBody(BaseModel):
path: str = "Succes Sheet .xlsx"
sheet: Optional[str] = "Projects next steps revenue"
replace: bool = True
@router.get("/revenue-cockpit", response_class=HTMLResponse)
async def revenue_cockpit_page(request: Request):
return templates.TemplateResponse(
"revenue_cockpit.html",
{"request": request, "page_title": "Revenue Cockpit"},
)
@api.get("/live")
async def live_from_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
"""Leading data source: fresh parse from NAS Excel."""
try:
parsed = await svc.fetch_excel_parse_async(path, sheet)
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
projects = parsed.get("projects") or []
margin_month = sum(p.get("margin_month") or 0 for p in projects)
margin_year = sum(p.get("margin_year") or 0 for p in projects)
by_style: dict[str, int] = {}
by_category: dict[str, int] = {}
for p in projects:
by_style[p.get("row_style") or "white"] = by_style.get(p.get("row_style") or "white", 0) + 1
by_category[p.get("category") or "deal"] = by_category.get(p.get("category") or "deal", 0) + 1
return {
"ok": True,
"source": "excel",
**parsed,
"aggregates": {
"total_margin_month": margin_month,
"total_margin_year": margin_year,
"with_margin": sum(1 for p in projects if p.get("margin_month")),
"by_style": by_style,
"by_category": by_category,
},
}
@api.get("/dashboard")
async def dashboard():
return {"ok": True, "stats": svc.dashboard_stats(), "projects": svc.list_projects()}
@api.get("/projects")
def list_projects(status: Optional[str] = None):
return {"ok": True, "items": svc.list_projects(status)}
@api.get("/projects/{project_id}")
def get_project(project_id: int):
p = svc.get_project(project_id)
if not p:
raise HTTPException(404, "Project not found")
return {"ok": True, "project": p}
@api.patch("/projects/{project_id}")
def patch_project(project_id: int, body: ProjectUpdateBody):
data = body.model_dump(exclude_unset=True)
row_style = data.pop("row_style", None)
if row_style is not None:
svc.set_project_row_style(project_id, row_style)
p = svc.update_project(project_id, data)
if not p:
raise HTTPException(404, "Project not found")
svc.take_snapshot()
return {"ok": True, "project": p}
@api.patch("/goals")
def patch_goals(body: GoalsUpdateBody):
data = body.model_dump(exclude_unset=True)
g = svc.update_goals(data)
if not g:
raise HTTPException(404, "Goals not found")
return {"ok": True, "goals": g}
@api.post("/projects/{project_id}/objectives")
def add_objective(project_id: int, body: ObjectiveBody):
try:
obj = svc.create_objective(project_id, body.title, body.description, body.priority)
except Exception as exc:
raise HTTPException(400, str(exc)) from exc
svc.take_snapshot()
return {"ok": True, "objective": obj}
@api.patch("/objectives/{objective_id}")
def patch_objective(objective_id: int, body: ObjectiveUpdateBody):
data = body.model_dump(exclude_unset=True)
obj = svc.update_objective(objective_id, data)
if not obj:
raise HTTPException(404, "Objective not found")
svc.take_snapshot()
return {"ok": True, "objective": obj}
@api.post("/projects/{project_id}/assign-task")
def assign_task(project_id: int, body: AssignTaskBody):
try:
task = svc.assign_agent_task(
project_id,
body.agent_name,
body.title,
body.description,
body.objective_id,
body.priority,
body.delegate_herman,
)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
svc.take_snapshot()
return {"ok": True, "task": task}
@api.post("/projects/{project_id}/delegate")
async def delegate_project(project_id: int, body: DelegateBody):
try:
result = await svc.delegate_via_herman(project_id, body.message)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return result
@api.get("/tasks")
def list_tasks(limit: int = 50):
return {"ok": True, "items": svc.list_agent_tasks(limit)}
@api.get("/snapshots")
def snapshots(limit: int = 90):
return {"ok": True, "items": svc.list_snapshots(limit)}
@api.post("/snapshot")
def create_snapshot():
snap = svc.take_snapshot()
return {"ok": True, "snapshot": snap}
@api.post("/import-from-excel")
async def import_from_excel(body: ImportBody):
try:
parsed = await svc.fetch_excel_parse_async(body.path, body.sheet)
result = svc.import_from_parsed(parsed, imported_by="ceo", replace=body.replace)
return {"ok": True, **result, "preview": {"project_count": parsed.get("project_count"), "sheet": parsed.get("sheet_name")}}
except Exception as exc:
raise HTTPException(502, f"Import failed: {exc}") from exc
@api.get("/preview-excel")
async def preview_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
try:
parsed = await svc.fetch_excel_parse_async(path, sheet)
return {"ok": True, **parsed}
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
+1 -1
View File
@@ -10,7 +10,7 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
allowed = ("email", "general", "permissions", "social", "ai")
return templates.TemplateResponse(
"settings.html",
{
+115
View File
@@ -381,3 +381,118 @@ def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
# ── LLM providers ──────────────────────────────────────────────────────────
from app.services import llm_router
class LlmProviderBody(BaseModel):
label: str = Field(..., max_length=128)
provider_type: str = Field(default="deepseek", max_length=48)
api_base_url: Optional[str] = None
api_key: Optional[str] = None
model: str = Field(default="deepseek-chat", max_length=128)
is_active: bool = True
is_default: bool = False
extra_config: dict[str, Any] = Field(default_factory=dict)
@settings_router.get("/llm/presets")
def llm_presets() -> dict[str, Any]:
return {"presets": llm_router.list_presets()}
@settings_router.get("/llm")
def llm_list() -> dict[str, Any]:
items = llm_router.list_providers()
default = next((i for i in items if i.get("is_default")), items[0] if items else None)
return {"providers": items, "default": default, "presets": llm_router.list_presets()}
@settings_router.post("/llm")
def llm_create(body: LlmProviderBody) -> dict[str, Any]:
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
model = body.model or (preset.get("models") or ["deepseek-chat"])[0]
if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one(
"""INSERT INTO llm_providers (
label, provider_type, api_base_url, api_key, model,
is_active, is_default, extra_config, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
RETURNING *""",
(
body.label,
body.provider_type,
base_url,
body.api_key or "",
model,
body.is_active,
body.is_default,
json.dumps(body.extra_config or {}),
),
)
return {"provider": llm_router._mask_provider(row)}
@settings_router.put("/llm/{provider_id}")
def llm_update(provider_id: int, body: LlmProviderBody) -> dict[str, Any]:
existing = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
if not existing:
raise HTTPException(404, "Provider niet gevonden")
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
base_url = (body.api_base_url or preset.get("api_base_url") or existing.get("api_base_url") or "").strip() or None
api_key = body.api_key if body.api_key else existing.get("api_key") or ""
if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one(
"""UPDATE llm_providers SET
label = %s, provider_type = %s, api_base_url = %s, api_key = %s, model = %s,
is_active = %s, is_default = %s, extra_config = %s::jsonb, updated_at = NOW()
WHERE id = %s RETURNING *""",
(
body.label,
body.provider_type,
base_url,
api_key,
body.model,
body.is_active,
body.is_default,
json.dumps(body.extra_config or {}),
provider_id,
),
)
return {"provider": llm_router._mask_provider(row)}
@settings_router.delete("/llm/{provider_id}")
def llm_delete(provider_id: int) -> dict[str, Any]:
row = fetch_one("SELECT is_default FROM llm_providers WHERE id = %s", (provider_id,))
if not row:
raise HTTPException(404, "Provider niet gevonden")
execute("DELETE FROM llm_providers WHERE id = %s", (provider_id,))
if row.get("is_default"):
execute(
"""UPDATE llm_providers SET is_default = TRUE, updated_at = NOW()
WHERE id = (SELECT id FROM llm_providers ORDER BY id LIMIT 1)"""
)
return {"ok": True}
@settings_router.post("/llm/{provider_id}/activate")
def llm_activate(provider_id: int) -> dict[str, Any]:
llm_router.set_default(provider_id)
row = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
return {"ok": True, "provider": llm_router._mask_provider(row)}
@settings_router.post("/llm/{provider_id}/test")
async def llm_test(provider_id: int) -> dict[str, Any]:
ok, msg = await llm_router.test_provider(provider_id)
if not ok:
raise HTTPException(502, msg)
return {"ok": True, "message": msg}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -191,3 +191,48 @@ def require_approved(request_id: int) -> dict[str, Any]:
if req.get("status") != "approved":
raise PermissionError(f"Request status is {req.get('status')}, approval required")
return req
def _auto_approve_policy() -> dict[str, bool]:
try:
row = fetch_one("SELECT value FROM app_settings WHERE key = 'sysops_auto_approve'")
if row and row.get("value"):
val = row["value"]
if isinstance(val, str):
return json.loads(val)
return dict(val)
except Exception:
pass
return {"maintenance_scan": True, "config_backup": True}
def try_auto_approve_and_execute(request_id: int) -> dict[str, Any] | None:
"""Auto-approve low-risk SysOps requests when policy allows."""
req = get_request(request_id)
if not req or req.get("status") != "pending":
return None
key = str(req.get("agent_key") or "").lower()
action = str(req.get("action_type") or "")
policy = _auto_approve_policy()
if key != "sysops" or not policy.get(action):
return None
approved = approve_request(request_id, approved_by="auto_policy")
import os
import httpx
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
result_payload: dict[str, Any] = {}
try:
if action == "config_backup":
with httpx.Client(timeout=180.0) as client:
resp = client.post(f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id})
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
elif action == "maintenance_scan":
with httpx.Client(timeout=120.0) as client:
resp = client.post(f"{tools_url}/ops/maintenance/scan")
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
except Exception as exc:
result_payload = {"ok": False, "detail": str(exc)}
executed = mark_executed(request_id, result_payload)
return {"approved": approved, "executed": executed, "auto": True}
+48
View File
@@ -0,0 +1,48 @@
"""Log agent activity to agent_events for terminals and audit."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import fetch_one
from app.services.agent_names import normalize_agent_key
def log_agent_event(
agent_name: str,
event_type: str,
title: str,
body: str = "",
*,
agent_type: Optional[str] = None,
status: str = "completed",
channel: str = "cockpit",
metadata: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
key = normalize_agent_key(agent_name)
if not key:
return None
meta = json.dumps(metadata or {})
row = fetch_one(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, agent_name, event_type, title, body, status, channel, metadata, created_at
""",
(
key,
agent_type or key,
event_type,
title[:255],
(body or "")[:8000],
status,
channel,
meta,
),
)
if not row:
return None
out = dict(row)
if out.get("created_at") and hasattr(out["created_at"], "isoformat"):
out["created_at"] = out["created_at"].isoformat()
return out
+140
View File
@@ -0,0 +1,140 @@
"""Agent handoffs and collaboration matrix."""
from __future__ import annotations
import json
import uuid
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services.agent_names import normalize_agent_key
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
elif k == "correlation_id" and v is not None:
out[k] = str(v)
return out
def list_collaboration() -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT from_agent, to_agent, handoff_type, description FROM agent_collaboration ORDER BY from_agent, to_agent"
)
return [dict(r) for r in rows]
def create_handoff(
from_agent: str,
to_agent: str,
*,
handoff_type: str = "partner",
payload: dict[str, Any] | None = None,
correlation_id: str | None = None,
status: str = "completed",
) -> dict[str, Any]:
src = normalize_agent_key(from_agent)
dst = normalize_agent_key(to_agent)
if not src or not dst:
raise ValueError("from_agent and to_agent required")
cid = correlation_id or str(uuid.uuid4())
try:
uuid.UUID(str(cid))
except ValueError:
cid = str(uuid.uuid4())
row = fetch_one(
"""
INSERT INTO agent_handoffs (correlation_id, from_agent, to_agent, handoff_type, payload, status, completed_at)
VALUES (%s::uuid, %s, %s, %s, %s::jsonb, %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END)
RETURNING *
""",
(cid, src, dst, handoff_type, json.dumps(payload or {}), status, status),
)
handoff = _serialize(row) or {}
meta = {
"correlation_id": cid,
"handoff_id": handoff.get("id"),
"target_agent": dst,
"source_agent": src,
"handoff_type": handoff_type,
}
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
return handoff
def _log_handoff_events(
src: str,
dst: str,
handoff_type: str,
payload: dict[str, Any],
meta: dict[str, Any],
cid: str,
) -> None:
title_out = f"{src} → {dst}: {handoff_type}"
title_in = f"Handoff van {src}: {handoff_type}"
body = json.dumps(payload)[:2000] if payload else ""
for agent, etype, title, extra in (
(src, "handoff_out", title_out, {"target_agent": dst}),
(dst, "handoff_in", title_in, {"source_agent": src}),
):
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
agent,
"agent_handoff",
etype,
title[:255],
body,
"completed",
"agents",
json.dumps({**meta, **extra}),
),
)
except Exception:
pass
def recent_handoffs(hours: int = 6) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT * FROM agent_handoffs
WHERE created_at >= NOW() - make_interval(hours => %s)
ORDER BY created_at DESC
LIMIT 200
""",
(max(1, min(hours, 168)),),
)
return [_serialize(r) for r in rows if r]
def peer_edges_live(hours: int = 6) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT from_agent, to_agent, handoff_type, COUNT(*) AS weight,
MAX(correlation_id::text) AS correlation_id
FROM agent_handoffs
WHERE created_at >= NOW() - make_interval(hours => %s)
AND status = 'completed'
GROUP BY from_agent, to_agent, handoff_type
""",
(max(1, min(hours, 168)),),
)
return [
{
"source": str(r["from_agent"]),
"target": str(r["to_agent"]),
"type": "live",
"handoff_type": r.get("handoff_type"),
"weight": int(r["weight"] or 1),
"correlation_id": r.get("correlation_id"),
}
for r in rows
]
+22
View File
@@ -0,0 +1,22 @@
"""Normalize legacy agent_name values to agent_souls keys."""
from __future__ import annotations
AGENT_NAME_MAP: dict[str, str] = {
"retail_360": "retail",
"retail_scraper": "retail",
"retail_crm": "retail",
"retail_intel": "retail",
"rss_feeds": "marketing",
"wholesale_scraper": "sourcing",
"halal_registry": "halal",
"branch_scraper": "sourcing",
"hermes": "herman",
"herman_delegate": "herman",
}
def normalize_agent_key(name: str | None) -> str:
key = (name or "").strip().lower()
if not key:
return ""
return AGENT_NAME_MAP.get(key, key)
+456
View File
@@ -0,0 +1,456 @@
"""Interactive agent terminal — parse and execute whitelisted commands."""
from __future__ import annotations
import json
import os
from typing import Any, Optional
import httpx
from app.db import fetch_all, fetch_one
from app.services import agent_integration, agent_souls, herman, webbuilder_agent
from app.services.agent_events_log import log_agent_event
from app.services.agent_names import normalize_agent_key
TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
Line = dict[str, Any]
GLOBAL_HELP = [
"help — dit overzicht",
"status — huidige status & taak",
"history — laatste events (in terminal)",
"handoff <agent> <type> [notitie]",
"say <bericht> — log een notitie",
"ask <vraag> — AI-antwoord in rol van deze agent",
]
AGENT_HELP: dict[str, list[str]] = {
"herman": [
"briefing — dagrapport genereren",
"<tekst> — vraag / opdracht aan Herman",
],
"browser": [
"monitor add <url> — site toevoegen aan monitor",
"monitor list — actieve sites",
"browse <url> — pagina openen",
"crawl — alle sites crawlen + parse-overzicht",
"parse — laatste parse-resultaten",
"parse <id> — parse van specifieke site",
"intel — hype & trend analyse (samenvatting)",
"intel <zoekterm> — filter analyse",
],
"research": ["run — research pipeline starten", "briefs — recente briefs"],
"retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
"sysops": [
"backup — config backup (approval)",
"scan — maintenance scan",
"topology — infra overzicht",
"status — sysops status",
],
"marketing": ["reco — aanbevelingen genereren"],
"packaging": ["design <brief> — packaging opdracht (via Herman)"],
"webbuilder": [
"build <opdracht> — website bouwen (agy op Hermes)",
"projects — bestaande website-projecten",
"status [project] — build-status",
"preview [project] — preview-URL",
],
"email": ["sync — inbox sync trigger"],
"finance": ["margins — marge-overzicht (placeholder)"],
}
def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
return {"type": line_type, "message": msg, "detail": detail}
def _help_lines(key: str) -> list[Line]:
lines = [_line("Beschikbare commando's:", line_type="output")]
for h in GLOBAL_HELP:
lines.append(_line(" " + h, line_type="output"))
extra = AGENT_HELP.get(key, [])
if extra:
lines.append(_line(f"— {key} —", line_type="output"))
for h in extra:
lines.append(_line(" " + h, line_type="output"))
return lines
def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
recent = soul.get("recent_events") or []
last = recent[0] if recent else {}
lines = [
_line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
_line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
_line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
_line(f"Events totaal: {soul.get('event_count') or 0}"),
]
resp = (soul.get("responsibilities") or "").strip()
if resp:
lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
return lines
async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
data.setdefault("detail", resp.text[:500])
return data
async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{TOOLS_URL}{path}")
try:
return resp.json()
except Exception:
return {"ok": False, "detail": resp.text[:500]}
async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
try:
return resp.json()
except Exception:
return {"ok": False, "detail": resp.text[:500]}
def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
dst = normalize_agent_key(dst_raw)
if not dst:
return [_line("Onbekende agent: " + dst_raw, line_type="error")]
payload = {"note": note} if note else {}
try:
agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
except Exception as exc:
return [_line("Handoff mislukt: " + str(exc), line_type="error")]
async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
name = soul.get("display_name") or key
role = soul.get("role_title") or ""
prompt = (
f"Je bent {name} ({key}), {role}. "
f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
)
result = await herman.chat(prompt, channel=f"terminal:{key}")
reply = (result.get("reply") or "").strip()
delegated = result.get("delegated_agents") or []
lines = [_line(reply or "(geen antwoord)", detail=reply)]
if delegated:
lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
log_agent_event(
key,
"terminal_out",
reply[:255] if reply else "Antwoord",
reply[:4000],
channel="terminal",
metadata={"delegated": delegated, "question": question[:500]},
)
return lines
def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
if result.get("message") and not result.get("results"):
return [_line(result["message"], line_type="output")]
lines: list[Line] = [
_line(
f"Crawl klaar — {result.get('sites', 0)} site(s), "
f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
)
]
for r in result.get("results") or []:
if r.get("status") == "ERROR":
lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
continue
flag = "△" if r.get("changed") else "✓"
lines.append(
_line(
f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
f"{r.get('links_count', 0)} links",
detail=(r.get("excerpt") or "")[:200],
)
)
headings = r.get("headings") or []
if headings:
lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
for link in (r.get("links_sample") or [])[:3]:
lbl = link.get("label") or link.get("href") or ""
lines.append(_line(" → " + lbl[:70], line_type="output"))
return lines
def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
if not items:
return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
for p in items:
lines.append(
_line(
f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
f"{p.get('word_count', 0)} woorden",
detail=(p.get("excerpt") or "")[:200],
)
)
if p.get("meta_description"):
lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
for h in (p.get("headings") or [])[:3]:
lines.append(_line(" · " + h[:90], line_type="output"))
return lines
async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
if key == "herman":
if cmd == "briefing":
try:
content = await herman.generate_briefing()
preview = (content or "")[:400]
log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
return [_line("CEO briefing gegenereerd.", detail=preview)]
except Exception as exc:
return [_line("Briefing mislukt: " + str(exc), line_type="error")]
result = await herman.chat(raw, channel="terminal")
reply = (result.get("reply") or "").strip()
delegated = result.get("delegated_agents") or []
lines = [_line(reply or "(geen antwoord)", detail=reply)]
if delegated:
lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
return lines
if key == "browser":
if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
url = args[1]
from app.services.monitor import add_site
site = add_site(url, name=url[:80])
log_agent_event(
"browser",
"monitor_site_added",
f"Terminal: site toegevoegd {url[:120]}",
"",
channel="terminal",
metadata={"url": url, "site_id": site.get("id")},
)
return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
if cmd == "monitor" and args and args[0].lower() == "list":
rows = fetch_all(
"SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
)
if not rows:
return [_line("Geen monitor-sites.")]
return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
if cmd == "browse" and args:
url = args[0]
data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
title = data.get("title") or data.get("url") or url
return [_line(f"Browse OK: {title}")]
if cmd == "crawl":
from app.services.monitor import trigger_crawl
site_id = int(args[0]) if args and str(args[0]).isdigit() else None
result = trigger_crawl(site_id)
log_agent_event(
"browser",
"monitor_crawl",
f"Terminal crawl ({result.get('sites', 0)} sites)",
json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
channel="terminal",
)
return _format_crawl_results(result)
if cmd == "parse":
from app.services.monitor import list_parse_results
sid = int(args[0]) if args and str(args[0]).isdigit() else None
return _format_parse_items(list_parse_results(site_id=sid, limit=10))
if cmd == "intel":
from app.services.monitor import build_parse_intelligence
q = " ".join(args) if args else None
data = build_parse_intelligence(query=q, limit=20)
lines = [
_line(
f"Analyse: {data['summary']['pages']} pagina's · "
f"{data['summary']['total_words']} woorden · "
f"{data['summary']['themes_detected']} trend-termen"
),
_line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
]
for t in (data.get("hype_terms") or [])[:12]:
flag = "★ " if t.get("cross_site") else ""
lines.append(
_line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
)
return lines
if key == "research":
if cmd == "run":
data = await _tools_post("/research/run")
ok = data.get("ok", True)
log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
if cmd == "briefs":
data = await _tools_get("/research/briefs")
items = data if isinstance(data, list) else data.get("items") or []
if not items:
return [_line("Geen briefs.")]
return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
if key == "retail":
if cmd == "rss":
data = await _tools_post("/retail/rss/refresh")
log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
if cmd == "scores":
data = await _tools_post("/retail/compute-opportunities")
return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
if key == "sysops":
if cmd == "backup":
data = await _tools_post("/ops/backup/run")
log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
if cmd == "scan":
data = await _tools_post("/ops/maintenance/scan")
return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
if cmd == "topology":
data = await _tools_get("/ops/topology")
return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
if cmd == "status":
data = await _tools_get("/ops/status")
return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
if key == "marketing" and cmd == "reco":
data = await _tools_post("/recommendations/generate")
return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
if key == "packaging" and cmd == "design":
brief = " ".join(args) or raw
result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
reply = (result.get("reply") or "").strip()
return [_line(reply, detail=reply)]
if key == "webbuilder":
if cmd == "build":
brief = rest or raw
if not brief:
return [_line("Gebruik: build <opdracht>", line_type="error")]
try:
outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
proj = outcome.get("project") or "?"
preview = outcome.get("preview_url") or "—"
return [
_line(f"Build gestart: {proj}"),
_line(f"Preview: {preview}", line_type="output"),
_line("Volg voortgang live in dit terminal-venster.", line_type="output"),
]
except Exception as exc:
return [_line("Build mislukt: " + str(exc), line_type="error")]
if cmd == "projects":
data = await webbuilder_agent.list_projects()
items = data.get("items") or []
if not items:
return [_line("Geen projecten op Hermes/NAS.")]
return [
_line(
f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
+ (" · index.html ✓" if p.get("has_index") else "")
)
for p in items
]
if cmd == "status":
slug = args[0] if args else ""
if not slug:
return [_line("Gebruik: status <project>", line_type="error")]
st = await webbuilder_agent.get_build_status(slug)
lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
tail = (st.get("log_tail") or "")[:300]
if tail:
lines.append(_line(tail, line_type="output"))
return lines
if cmd == "preview":
slug = args[0] if args else ""
if slug:
st = await webbuilder_agent.get_build_status(slug)
url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
else:
url = webbuilder_agent.HERMES_PREVIEW_BASE
return [_line(f"Preview: {url}")]
return await _agent_ask(key, soul, raw)
async def execute_terminal_command(
agent_key: str,
command: str,
*,
issued_by: str = "ceo",
) -> dict[str, Any]:
key = normalize_agent_key(agent_key)
soul = agent_souls.get_soul(key)
if not soul:
raise ValueError("Agent niet gevonden")
raw = (command or "").strip()
if not raw:
raise ValueError("Leeg commando")
log_agent_event(
key,
"terminal_in",
raw[:255],
"",
channel="terminal",
metadata={"issued_by": issued_by, "command": raw[:500]},
)
parts = raw.split()
cmd = parts[0].lower()
args = parts[1:]
rest = " ".join(args)
if cmd in ("help", "?", "h"):
lines = _help_lines(key)
elif cmd == "status":
lines = _status_lines(key, soul)
elif cmd == "handoff":
if len(args) < 2:
lines = [_line("Gebruik: handoff <agent> <type> [notitie]", line_type="error")]
else:
note = " ".join(args[2:]) if len(args) > 2 else ""
lines = _handoff(key, args[0], args[1], note)
elif cmd == "say":
if not rest:
lines = [_line("Gebruik: say <bericht>", line_type="error")]
else:
log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
lines = [_line("Genoteerd.")]
elif cmd == "ask":
if not rest:
lines = [_line("Gebruik: ask <vraag>", line_type="error")]
else:
lines = await _agent_ask(key, soul, rest)
elif cmd == "history":
events = agent_souls.list_agent_events(key, limit=8)
if not events:
lines = [_line("Geen history.")]
else:
lines = []
for ev in reversed(events):
t = (ev.get("created_at") or "")[11:19] or "--:--"
lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
else:
lines = await _run_agent_command(key, soul, cmd, args, raw)
return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
+130
View File
@@ -0,0 +1,130 @@
"""Automatische NAS + second-brain synchronisatie."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from typing import Any
import httpx
from app.db import execute, fetch_all, fetch_one
log = logging.getLogger("cockpit.auto_ingest")
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
try:
execute(
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
(source, status, json.dumps(details)),
)
except Exception as exc:
log.warning("ingest log failed: %s", exc)
def _brain_chat_id_for_client(client_id: int) -> int:
return -int(client_id)
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
rows = fetch_all(
"""
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
da.word_count, dl.client_id, c.name AS client_name
FROM document_analytics da
LEFT JOIN document_links dl ON (
da.storage_path = dl.storage_path
OR da.storage_path LIKE dl.storage_path || '/%%'
) AND dl.is_folder = TRUE
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
WHERE da.storage_path IS NOT NULL
ORDER BY da.analyzed_at DESC NULLS LAST
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
synced = 0
errors = 0
async with httpx.AsyncClient(timeout=120.0) as client:
for row in rows:
path = row.get("storage_path") or ""
if not path:
continue
client_id = row.get("client_id")
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
text = (
f"NAS document: {row.get('filename') or path}\n"
f"Pad: {path}\nType: {row.get('doc_type')}\n"
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
)
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
try:
r = await client.post(
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
json={
"chat_id": chat_id,
"direction": "inbound",
"role": "system",
"content_type": "document",
"content_text": text[:4000],
"agent_name": "auto-ingest",
"embed": True,
"chat_type": "client_brain" if client_id else "nas_corpus",
"user_name": row.get("client_name") or "NAS",
"content_json": {"storage_path": path, "client_id": client_id},
"telegram_message_id": msg_hash,
},
)
if r.status_code < 400:
synced += 1
else:
errors += 1
except Exception as exc:
log.warning("brain sync %s: %s", path, exc)
errors += 1
return {"synced": synced, "errors": errors, "candidates": len(rows)}
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
out: dict[str, Any] = {"ok": True, "steps": {}}
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
params={"force": "true" if force_scan else "false"},
)
r.raise_for_status()
out["steps"]["nas_scan"] = r.json()
except Exception as exc:
out["steps"]["nas_scan"] = {"error": str(exc)}
brain = await sync_nas_to_brain()
out["steps"]["brain_sync"] = brain
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
_log_run("auto_sync", status, out)
out["last_logged"] = True
return out
def last_auto_sync() -> dict[str, Any] | None:
row = fetch_one(
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
)
if not row:
return None
d = dict(row)
if hasattr(d.get("created_at"), "isoformat"):
d["created_at"] = d["created_at"].isoformat()
if isinstance(d.get("details"), str):
try:
d["details"] = json.loads(d["details"])
except Exception:
pass
return d
+28 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
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 +166,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
@@ -565,6 +575,13 @@ 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]
@@ -644,6 +661,15 @@ def build_template_report(data: dict[str, Any]) -> str:
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"):
lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
@@ -683,7 +709,7 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
"Schrijf warm, professioneel, actionable."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
+147
View File
@@ -0,0 +1,147 @@
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
from __future__ import annotations
from typing import Any
from app.db import fetch_all, fetch_one
from app.services import projects as projects_svc
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _serialize_rows(rows: list) -> list[dict]:
return [_serialize_row(r) for r in rows if r]
def get_client_360(client_id: int) -> dict[str, Any]:
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
if not client:
return {"ok": False, "error": "Client not found"}
projs = projects_svc.list_projects(client_id=client_id, limit=50)
links = fetch_all(
"""
SELECT dl.*, p.name AS project_name
FROM document_links dl
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
WHERE dl.client_id = %s
ORDER BY dl.created_at DESC
""",
(client_id,),
)
paths = [l["storage_path"] for l in links if l.get("storage_path")]
docs: list[dict] = []
if paths:
placeholders = ",".join(["%s"] * len(paths))
docs = fetch_all(
f"""
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
sentiment_compound, analyzed_at, user_labels
FROM document_analytics
WHERE storage_path IN ({placeholders})
OR storage_path LIKE ANY (
SELECT dl.storage_path || '/%%' FROM document_links dl
WHERE dl.client_id = %s AND dl.is_folder = TRUE
)
ORDER BY analyzed_at DESC NULLS LAST
LIMIT 80
""",
tuple(paths + [client_id]),
)
sentiment_rows = fetch_all(
"""
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
FROM document_analytics da
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY sentiment_label
""",
(client_id,),
)
top_words = fetch_all(
"""
SELECT dwc.lemma, SUM(dwc.count) AS total
FROM document_word_counts dwc
JOIN document_analytics da ON da.storage_path = dwc.storage_path
WHERE EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
AND NOT dwc.is_stopword
GROUP BY dwc.lemma
ORDER BY total DESC
LIMIT 25
""",
(client_id,),
)
deals = fetch_all(
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
(client_id,),
)
stores = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
FROM client_supermarket_links l
JOIN supermarkets s ON s.id = l.supermarket_id
WHERE l.client_id = %s
LIMIT 30
""",
(client_id,),
)
trends = fetch_all(
"""
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
COUNT(*) AS docs,
AVG(da.sentiment_compound) AS avg_sentiment,
SUM(da.word_count) AS words
FROM document_analytics da
WHERE da.analyzed_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM document_links dl
WHERE dl.client_id = %s
AND (da.storage_path = dl.storage_path
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
)
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12
""",
(client_id,),
)
for t in trends:
if hasattr(t.get("month"), "isoformat"):
t["month"] = t["month"].isoformat()
return {
"ok": True,
"client": _serialize_row(client),
"projects": projs,
"links": _serialize_rows(links),
"documents": _serialize_rows(docs),
"sentiment_breakdown": _serialize_rows(sentiment_rows),
"top_words": _serialize_rows(top_words),
"deals": _serialize_rows(deals),
"stores": _serialize_rows(stores),
"monthly_trends": trends,
"stats": {
"linked_paths": len(links),
"documents": len(docs),
"projects": len(projs),
"stores": len(stores),
"deals": len(deals),
},
}
+152
View File
@@ -0,0 +1,152 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: foodlinkk_redis
ports:
- "6379:6379"
restart: unless-stopped
tools-api:
build: ./tools-api
container_name: foodlinkk_tools_api
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
BACKUP_ROOT: /data/backup-root
GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
GITEA_SYSOPS_USER: sysops
GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
GITEA_SYSOPS_NAME: sysops
GITEA_BACKUP_BRANCH: main
PROXMOX_HOST: 10.4.7.14
VM106_IP: 10.4.7.18
OLLAMA_URL: http://10.4.7.19:11434
OLLAMA_MODEL: qwen3:8b
CHROMA_HOST: 10.4.7.19
CHROMA_PORT: "8000"
DOC_INGEST_URL: http://10.4.7.19:8750
BROWSER_USE_URL: http://browser-agent:7790
BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
ports:
- "8700:8700"
volumes:
- /home/aissa/foodlinkk-command-center:/data/backup-root:ro
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
cockpit:
build: ./cockpit
container_name: foodlinkk_cockpit
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
VNC_CDP_URL: http://10.4.7.18:9223
OLLAMA_URL: http://10.4.7.19:11434
OLLAMA_MODEL: qwen3:8b
TOOLS_API_URL: http://tools-api:8700
CHROMA_HOST: 10.4.7.19
CHROMA_PORT: "8000"
DOC_INGEST_URL: http://10.4.7.19:8750
BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
NAS_CLIENTS_ROOT: /data/nas-clients
NAS_SHARE_ROOT: /data/nas-share
ports:
- "8600:8600"
volumes:
- /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro
depends_on:
- tools-api
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
browser-agent:
build: ./browser-agent
container_name: foodlinkk_browser_agent
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
VNC_CDP_URL: http://10.4.7.18:9223
ports:
- "7790:7790"
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
shm_size: "1gb"
gitea:
image: gitea/gitea:1.21-rootless
container_name: foodlinkk_gitea
environment:
GITEA__database__DB_TYPE: sqlite3
GITEA__server__ROOT_URL: http://10.4.7.18:3001/
GITEA__server__HTTP_PORT: 3001
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY: foodlinkk-gitea-secret-2026
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "3001:3001"
volumes:
- gitea_data:/var/lib/gitea
restart: unless-stopped
email-agent:
build: ./email-agent
container_name: foodlinkk_email_agent
environment:
DB_HOST: foodlinkk_db
DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk
TOOLS_API_URL: http://tools-api:8700
SYNC_INTERVAL_SEC: 300
ports:
- "8801:8801"
depends_on:
- tools-api
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
prometheus:
image: prom/prometheus:v2.53.0
container_name: foodlinkk_prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
restart: unless-stopped
grafana:
image: grafana/grafana:11.1.0
container_name: foodlinkk_grafana
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: Foodlinkk#2026
GF_SERVER_ROOT_URL: http://10.4.7.18:3002
ports:
- "3002:3000"
depends_on:
- prometheus
restart: unless-stopped
volumes:
gitea_data:
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
"""Parse Succes Sheet revenue tab — runs in cockpit with NAS mount."""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
NAS_SHARE_ROOT = Path(os.getenv("NAS_SHARE_ROOT", "/data/nas-share"))
DEFAULT_FILE = os.getenv("CEO_EXCEL_PATH", "Succes Sheet .xlsx")
DEFAULT_SHEET = os.getenv("CEO_EXCEL_SHEET", "Projects next steps revenue")
_FILL_STYLE = {
"FF92D050": "green",
"FFFF0000": "red",
"FFFFC000": "orange",
"FFFFFF00": "yellow",
"FFBDD7EE": "blue",
"FFDDEBF7": "blue",
"FF0070C0": "blue",
}
def _num(val: Any) -> float | None:
if val is None or val == "":
return None
try:
return float(val)
except (TypeError, ValueError):
return None
def _txt(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def _cell_fill_style(cell) -> str:
try:
if not cell or not cell.fill or cell.fill.fill_type != "solid":
return "white"
rgb = cell.fill.fgColor.rgb if cell.fill.fgColor else None
if not rgb or rgb in ("00000000", "FFFFFFFF", "00FFFFFF"):
return "white"
key = rgb[-8:].upper() if len(rgb) >= 8 else rgb.upper()
if key in _FILL_STYLE:
return _FILL_STYLE[key]
short = key[-6:]
for k, v in _FILL_STYLE.items():
if k.endswith(short):
return v
return "white"
except Exception:
return "white"
def parse_revenue_sheet(
rel_path: str = DEFAULT_FILE,
sheet_name: str | None = DEFAULT_SHEET,
nas_root: Path | None = None,
) -> dict[str, Any]:
try:
from openpyxl import load_workbook
except ImportError as exc:
return {"ok": False, "error": f"openpyxl not installed: {exc}"}
root = nas_root or NAS_SHARE_ROOT
full = root / rel_path.lstrip("/")
if not full.is_file():
return {"ok": False, "error": f"File not found: {full}"}
st = full.stat()
wb = load_workbook(full, read_only=False, data_only=True)
names = wb.sheetnames
target_name = sheet_name
if not target_name:
for n in names:
if "project" in n.lower() and "revenue" in n.lower():
target_name = n
break
if not target_name and len(names) > 2:
target_name = names[2]
if not target_name:
return {"ok": False, "error": "Sheet not found", "sheetnames": names}
ws = wb[target_name]
rows = list(ws.iter_rows(values_only=False))
goals: dict[str, str] = {"vision_text": "", "horizon_text": "", "mid_text": "", "tagline": ""}
if rows:
r0 = rows[0]
goals["vision_text"] = _txt(r0[0].value if len(r0) > 0 else "")
goals["horizon_text"] = _txt(r0[1].value if len(r0) > 1 else "")
goals["mid_text"] = _txt(r0[2].value if len(r0) > 2 else "")
goals["tagline"] = _txt(r0[4].value if len(r0) > 4 else (_txt(r0[3].value if len(r0) > 3 else "")))
projects: list[dict[str, Any]] = []
sort_order = 0
for idx, row in enumerate(rows):
if idx <= 1:
continue
cells = list(row) if row else []
name = _txt(cells[1].value if len(cells) > 1 else "")
if not name:
continue
margin_month = _num(cells[2].value if len(cells) > 2 else None)
margin_year = _num(cells[3].value if len(cells) > 3 else None)
next_steps = _txt(cells[4].value if len(cells) > 4 else "")
target_extra = _num(cells[5].value if len(cells) > 5 else None)
row_style = _cell_fill_style(cells[1] if len(cells) > 1 else None)
category = "deal" if margin_month is not None or margin_year is not None else "initiative"
if row_style == "yellow" or re.search(r"foodlinkk|linknbit|subsid|total earnings|loonkosten", name, re.I):
category = "strategic"
projects.append(
{
"name": name,
"category": category,
"margin_month": margin_month,
"margin_year": margin_year,
"target_revenue": target_extra or margin_year,
"next_steps": next_steps,
"status": "active",
"sort_order": sort_order,
"source_row": idx,
"row_style": row_style,
}
)
sort_order += 1
wb.close()
return {
"ok": True,
"source_file": rel_path,
"sheet_name": target_name,
"sheetnames": names,
"file_mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"file_size": st.st_size,
"goals": goals,
"projects": projects,
"parsed_at": datetime.now(timezone.utc).isoformat(),
"project_count": len(projects),
}
+141 -10
View File
@@ -6,6 +6,8 @@ from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
from app.services import packaging_agent
from app.services import webbuilder_agent
from app.services import voice_export_actions
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
@@ -49,7 +51,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 +67,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 +77,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 +92,113 @@ def _pick_agent(raw: str) -> str:
return k
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dict] | None = None) -> list[dict[str, str]]:
steps: list[dict[str, str]] = []
for a in delegated:
key = (a or "").strip().lower()
if key and key != "herman":
steps.append({"agent": key, "status": "delegated", "message": routing_reason or "Aangestuurd door Herman"})
if extra:
steps.extend(extra)
return steps
async def chat(
message: str,
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: str | None = None,
) -> dict[str, Any]:
use_voice_router = (
channel in voice_export_actions.VOICE_CHANNELS
or bool(session_id)
or voice_export_actions.wants_export_search(message)
or webbuilder_agent.wants_website(message)
)
if use_voice_router:
routed = await voice_export_actions.handle_voice_command(
message,
session_id=session_id,
confirm_action_id=confirm_action_id,
channel=channel,
)
if routed is not None:
await _log_event(
"herman",
"voice_command" if channel == "voice" else "browser_command",
(routed.get("reply") or "")[:120],
message[:2000],
{
"pending_action": routed.get("pending_action"),
"ui_actions": routed.get("ui_actions"),
"channel": channel,
},
channel=channel,
)
return routed
if 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 +227,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 +241,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 +288,16 @@ async def chat(message: str) -> dict[str, Any]:
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename, "channel": channel}, channel=channel)
delegated = ["design"]
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
"delegated_agents": delegated,
"agent_steps": _agent_steps(delegated, "Afbeelding gegenereerd"),
}
except Exception as exc:
return {
@@ -184,24 +310,29 @@ async def chat(message: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
json={"message": message, "agent": "default", "use_crm": True, "use_rag": True, "channel": channel},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
reason = data.get("routing_reason", "") or ""
await _log_event(
"herman",
"openswarm_delegation",
"voice_delegation" if channel == "voice" else ("telegram_delegation" if channel == "telegram" else "openswarm_delegation"),
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "reason": data.get("routing_reason", "")},
{"delegated": delegated, "delegated_agents": delegated, "reason": reason, "channel": channel},
channel=channel,
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": data.get("routing_reason", ""),
"routing_reason": reason,
"agent_steps": _agent_steps(delegated, reason),
"rag_sources": data.get("rag_sources") or [],
"crm_loaded": data.get("crm_loaded", False),
}
except Exception as exc:
return {
+277
View File
@@ -0,0 +1,277 @@
"""Unified LLM router — Ollama, DeepSeek, Gemini, Groq, OpenRouter, custom OpenAI-compatible."""
from __future__ import annotations
import json
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import ollama
# Preset catalog for Settings UI (signup links + default models)
LLM_PRESETS: dict[str, dict[str, Any]] = {
"ollama": {
"label": "Ollama (lokaal)",
"api_base_url": "",
"models": ["qwen3:8b", "gemma3:12b", "llama3.2", "mistral"],
"needs_key": False,
"hint": "Geen API key — draait op je Ollama server.",
},
"deepseek": {
"label": "DeepSeek",
"api_base_url": "https://api.deepseek.com/v1",
"models": ["deepseek-chat", "deepseek-reasoner"],
"needs_key": True,
"signup_url": "https://platform.deepseek.com/",
"hint": "Goedkoop · sterk voor code en analyse.",
},
"gemini": {
"label": "Google Gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"models": ["gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"],
"needs_key": True,
"signup_url": "https://aistudio.google.com/apikey",
"hint": "Gratis tier via Google AI Studio.",
},
"groq": {
"label": "Groq (snel · gratis tier)",
"api_base_url": "https://api.groq.com/openai/v1",
"models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
"needs_key": True,
"signup_url": "https://console.groq.com/",
"hint": "Zeer snelle inference · gratis limiet.",
},
"openrouter": {
"label": "OpenRouter",
"api_base_url": "https://openrouter.ai/api/v1",
"models": [
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-r1:free",
"meta-llama/llama-3.3-70b-instruct:free",
],
"needs_key": True,
"signup_url": "https://openrouter.ai/",
"hint": "Veel gratis modellen via één API.",
},
"mistral": {
"label": "Mistral AI",
"api_base_url": "https://api.mistral.ai/v1",
"models": ["mistral-small-latest", "open-mistral-nemo"],
"needs_key": True,
"signup_url": "https://console.mistral.ai/",
"hint": "EU-hosted · gratis proef tier.",
},
"custom_openai": {
"label": "Custom OpenAI-compatible",
"api_base_url": "",
"models": [],
"needs_key": True,
"hint": "Elke API die /v1/chat/completions ondersteunt.",
},
}
def list_presets() -> list[dict[str, Any]]:
out = []
for key, meta in LLM_PRESETS.items():
row = dict(meta)
row["id"] = key
out.append(row)
return out
def _mask_provider(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
if isinstance(out.get("extra_config"), str):
try:
out["extra_config"] = json.loads(out["extra_config"])
except Exception:
out["extra_config"] = {}
out["api_key_set"] = bool(row.get("api_key"))
out.pop("api_key", None)
preset = LLM_PRESETS.get(out.get("provider_type") or "", {})
out["preset_label"] = preset.get("label", out.get("provider_type"))
out["needs_key"] = preset.get("needs_key", True)
return out
def list_providers() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM llm_providers ORDER BY is_default DESC, is_active DESC, id ASC")
return [_mask_provider(r) for r in rows if r]
def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
if provider_id:
return fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
row = fetch_one(
"SELECT * FROM llm_providers WHERE is_default = TRUE ORDER BY id LIMIT 1"
)
if row:
return row
row = fetch_one(
"SELECT * FROM llm_providers WHERE is_active = TRUE ORDER BY id LIMIT 1"
)
if row:
return row
return fetch_one("SELECT * FROM llm_providers ORDER BY id LIMIT 1")
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
row = get_provider(provider_id)
if not row:
return {
"id": 0,
"label": "Ollama lokaal",
"provider_type": "ollama",
"api_base_url": settings.OLLAMA_URL,
"api_key": "",
"model": settings.OLLAMA_MODEL,
"extra_config": {},
}
return row
async def chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
) -> tuple[str, dict[str, Any]]:
"""Returns (reply_text, meta dict with provider info)."""
prov = resolve_provider(provider_id)
ptype = (prov.get("provider_type") or "ollama").lower()
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
ollama_timeout = min(timeout, 85.0) if ptype == "ollama" else timeout
if ptype == "ollama":
try:
reply = await ollama.chat_messages(messages, timeout=ollama_timeout, model=use_model)
except Exception as exc:
raise RuntimeError(
f"Ollama timeout/ offline ({exc}). "
"Voeg DeepSeek of Gemini toe via Instellingen → AI / LLM voor snelle cloud-chat."
) from exc
return reply, {
"provider_id": prov.get("id"),
"provider_type": "ollama",
"provider_label": prov.get("label") or "Ollama",
"model": use_model,
}
api_key = (prov.get("api_key") or "").strip()
if not api_key:
raise RuntimeError(
f"Geen API key voor {prov.get('label') or ptype} — voeg key toe in Instellingen → AI / LLM"
)
base = (prov.get("api_base_url") or "").strip().rstrip("/")
if not base:
preset = LLM_PRESETS.get(ptype, {})
base = (preset.get("api_base_url") or "").rstrip("/")
if not base:
raise RuntimeError(f"Geen API URL voor provider {prov.get('label')}")
extra = prov.get("extra_config") or {}
if isinstance(extra, str):
try:
extra = json.loads(extra)
except Exception:
extra = {}
url = f"{base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if ptype == "openrouter":
headers["HTTP-Referer"] = extra.get("referer", "https://foodlinkk.local")
headers["X-Title"] = extra.get("title", "Foodlinkk Command Center")
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(extra.get("temperature", 0.4)),
"max_tokens": int(extra.get("max_tokens", 2048)),
}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code >= 400:
detail = resp.text[:500]
try:
detail = resp.json().get("error", {}).get("message", detail)
except Exception:
pass
raise RuntimeError(f"{prov.get('label')}: {detail}")
data = resp.json()
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"{prov.get('label')}: leeg antwoord")
content = (choices[0].get("message") or {}).get("content") or ""
return content.strip(), {
"provider_id": prov.get("id"),
"provider_type": ptype,
"provider_label": prov.get("label"),
"model": use_model,
}
async def generate(
prompt: str,
system: str | None = None,
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
) -> str:
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
reply, _meta = await chat_messages(
messages, provider_id=provider_id, model=model, timeout=timeout
)
return reply
async def test_provider(provider_id: int) -> tuple[bool, str]:
prov = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
if not prov:
return False, "Provider niet gevonden"
try:
reply, meta = await chat_messages(
[{"role": "user", "content": "Antwoord met exact één woord: OK"}],
provider_id=provider_id,
timeout=60.0,
)
msg = f"{meta.get('provider_label')} · {meta.get('model')} — {reply[:80]}"
execute(
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
("ok", msg, provider_id),
)
return True, msg
except Exception as exc:
execute(
"""UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
("error", str(exc)[:500], provider_id),
)
return False, str(exc)
def set_default(provider_id: int) -> None:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
execute(
"UPDATE llm_providers SET is_default = TRUE, is_active = TRUE, updated_at = NOW() WHERE id = %s",
(provider_id,),
)
+529 -62
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from urllib.parse import urlparse
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from app.db import execute, fetch_one, get_connection
from app.db import execute, fetch_all, fetch_one, get_connection
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
@@ -45,6 +46,59 @@ def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
return None
def _extract_parse_info(
html: str,
text: str,
title: str,
final_url: str,
base_url: str,
) -> dict[str, Any]:
soup = BeautifulSoup(html or "", "html.parser")
headings: list[str] = []
for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
if t:
headings.append(t[:140])
links_sample: list[dict[str, str]] = []
seen_hrefs: set[str] = set()
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
continue
if not href.startswith("http"):
href = urljoin(base_url or final_url, href)
if href in seen_hrefs:
continue
seen_hrefs.add(href)
label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
links_sample.append({"href": href, "label": label or href})
if len(links_sample) >= 10:
break
words = len(text.split()) if text else 0
excerpt = ""
if text:
excerpt = text[:320] + ("…" if len(text) > 320 else "")
meta_desc = ""
md = soup.find("meta", attrs={"name": "description"})
if md and md.get("content"):
meta_desc = str(md["content"]).strip()[:240]
return {
"title": title or "(geen titel)",
"final_url": final_url,
"word_count": words,
"char_count": len(text or ""),
"excerpt": excerpt,
"meta_description": meta_desc,
"headings": headings,
"links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
"links_sample": links_sample,
}
def get_page_hash(url: str) -> str | None:
fetched = _fetch_page(url)
if not fetched:
@@ -53,20 +107,31 @@ def get_page_hash(url: str) -> str | None:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
import json
def _save_snapshot(
site_id: int,
url: str,
final_url: str,
title: str,
text: str,
html: str,
parse_info: dict[str, Any] | None = None,
) -> int | None:
parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
metadata = {"source": "monitor", "parse": parse_info}
links_json = json.dumps(parse_info.get("links_sample") or [])
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
site_id=EXCLUDED.site_id, crawled_at=NOW()
site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
links=EXCLUDED.links, crawled_at=NOW()
RETURNING id
""",
(
@@ -76,7 +141,8 @@ def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str
text[:50000],
html[:100000],
site_id,
json.dumps({"source": "monitor"}),
json.dumps(metadata),
links_json,
),
)
page_id = cur.fetchone()[0]
@@ -125,7 +191,8 @@ def add_site(url: str, name: str) -> dict:
)
site_id = cur.fetchone()[0]
if fetched:
_save_snapshot(site_id, url, final_url, title, text, html)
parse_info = _extract_parse_info(html, text, title, final_url, url)
_save_snapshot(site_id, url, final_url, title, text, html, parse_info)
row = fetch_one(
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
(site_id,),
@@ -142,61 +209,461 @@ def remove_site(site_id: int, soft: bool = True) -> None:
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
def trigger_crawl(site_id: int | None = None) -> dict:
def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
site_id = int(site["id"])
url = site["url"]
name = site.get("name") or url
result: dict[str, Any] = {
"site_id": site_id,
"url": url,
"name": name,
"status": "ERROR",
"changed": False,
"error": None,
}
fetched = _fetch_page(url)
if not fetched:
msg = f"Kan {url} niet bereiken"
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "ERROR", msg),
)
result["error"] = msg
return result
final_url, title, text, html = fetched
parse_info = _extract_parse_info(html, text, title, final_url, url)
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
old_hash = site.get("last_hash")
changed = bool(old_hash and old_hash != new_hash)
if changed:
execute(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site_id, old_hash, new_hash),
)
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
)
result["status"] = "CHANGE"
else:
execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
)
result["status"] = "OK"
execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(new_hash, title, site_id),
)
snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
result.update(parse_info)
result["changed"] = changed
result["snapshot_id"] = snapshot_id
return result
def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
if site_id:
row = fetch_one(
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [dict(row)] if row else []
else:
sites = [
dict(r)
for r in fetch_all(
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
)
]
if not sites:
return {
"ok": True,
"method": "inline",
"sites": 0,
"changed": 0,
"errors": 0,
"results": [],
"message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
}
results: list[dict[str, Any]] = []
changed = 0
errors = 0
for site in sites:
row = _crawl_one_site(site)
results.append(row)
if row.get("status") == "ERROR":
errors += 1
if row.get("changed"):
changed += 1
return {
"ok": errors < len(sites),
"method": "inline",
"sites": len(sites),
"changed": changed,
"errors": errors,
"results": results,
}
def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
limit = max(1, min(limit, 50))
if site_id:
rows = fetch_all(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id = %s
ORDER BY cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(site_id, limit),
)
else:
rows = fetch_all(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
ORDER BY cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(limit,),
)
out: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
meta = item.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
parse = (meta or {}).get("parse") or {}
content = item.get("content") or ""
if not parse.get("excerpt") and content:
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
if not parse.get("word_count") and content:
parse["word_count"] = len(str(content).split())
links = item.get("links") or []
if isinstance(links, str):
try:
links = json.loads(links)
except Exception:
links = []
if not parse.get("links_sample") and links:
parse["links_sample"] = links
crawled = item.get("crawled_at")
if crawled is not None and hasattr(crawled, "isoformat"):
item["crawled_at"] = crawled.isoformat()
out.append(
{
"id": item.get("id"),
"site_id": item.get("site_id"),
"site_name": item.get("site_name"),
"url": item.get("url"),
"final_url": item.get("final_url"),
"title": item.get("title") or parse.get("title"),
"crawled_at": item.get("crawled_at"),
"word_count": parse.get("word_count", 0),
"excerpt": parse.get("excerpt", ""),
"meta_description": parse.get("meta_description", ""),
"headings": parse.get("headings") or [],
"links_count": parse.get("links_count", len(links)),
"links_sample": parse.get("links_sample") or links[:10],
}
)
return out
NL_STOPWORDS = frozenset(
"""
de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
for on at by from with about into through during before after above below between under again
further then once here there when where why how all each few more most other some such no nor
not only own same so than too very just don should now naar website home pagina menu contact
service cookie cookies privacy login inloggen registreren meer lees read click klik
""".split()
)
def _tokens(text: str, min_len: int = 4) -> list[str]:
if not text:
return []
raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
item = dict(row)
meta = item.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
parse = (meta or {}).get("parse") or {}
content = str(item.get("content") or "")
if not parse.get("excerpt") and content:
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
if not parse.get("word_count") and content:
parse["word_count"] = len(content.split())
links = item.get("links") or []
if isinstance(links, str):
try:
links = json.loads(links)
except Exception:
links = []
if not parse.get("links_sample") and links:
parse["links_sample"] = links
crawled = item.get("crawled_at")
if crawled is not None and hasattr(crawled, "isoformat"):
crawled = crawled.isoformat()
content_read = content[:content_limit]
if len(content) > content_limit:
content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
return {
"id": item.get("id"),
"site_id": item.get("site_id"),
"site_name": item.get("site_name"),
"url": item.get("url"),
"final_url": item.get("final_url"),
"title": item.get("title") or parse.get("title"),
"crawled_at": crawled,
"word_count": parse.get("word_count", 0),
"char_count": parse.get("char_count", len(content)),
"excerpt": parse.get("excerpt", ""),
"meta_description": parse.get("meta_description", ""),
"headings": parse.get("headings") or [],
"links_count": parse.get("links_count", len(links)),
"links_sample": parse.get("links_sample") or links[:15],
"content_read": content_read,
"content_length": len(content),
"has_full_content": len(content) > 0,
}
def get_parse_page(page_id: int) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.id = %s
""",
(page_id,),
)
if not row:
return None
page = _normalize_page_row(dict(row), content_limit=50000)
page["content_full"] = str(dict(row).get("content") or "")
return page
def build_parse_intelligence(
site_id: int | None = None,
query: str | None = None,
limit: int = 30,
) -> dict[str, Any]:
"""Aggregate parsed pages for analysis — hype terms, trends, readable content."""
limit = max(1, min(limit, 100))
if site_id:
rows = fetch_all(
"""
SELECT DISTINCT ON (cp.site_id)
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id = %s
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
""",
(site_id,),
)
else:
rows = fetch_all(
"""
SELECT DISTINCT ON (cp.site_id)
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
cp.crawled_at, cp.site_id, ms.name AS site_name
FROM crawled_pages cp
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
WHERE cp.site_id IS NOT NULL
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
LIMIT %s
""",
(limit,),
)
pages = [_normalize_page_row(dict(r)) for r in rows]
q = (query or "").strip().lower()
if q:
pages = [
p
for p in pages
if q in (p.get("title") or "").lower()
or q in (p.get("content_read") or "").lower()
or q in (p.get("excerpt") or "").lower()
or any(q in h.lower() for h in p.get("headings") or [])
]
changed_site_ids: set[int] = set()
recent_changes: list[dict[str, Any]] = []
try:
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
return {"ok": True, "method": "worker"}
change_rows = fetch_all(
"""
SELECT pc.site_id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC
LIMIT 30
"""
)
for cr in change_rows:
sid = int(cr["site_id"])
changed_site_ids.add(sid)
ts = cr.get("changed_at")
if ts is not None and hasattr(ts, "isoformat"):
ts = ts.isoformat()
recent_changes.append(
{
"site_id": sid,
"site_name": cr.get("name"),
"url": cr.get("url"),
"changed_at": ts,
}
)
except Exception:
pass
from app.db import fetch_all
term_scores: dict[str, dict[str, Any]] = {}
heading_counts: dict[str, dict[str, Any]] = {}
if site_id:
row = fetch_one(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [row] if row else []
else:
sites = fetch_all(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
)
def bump_term(term: str, site_name: str, weight: int = 1) -> None:
if len(term) < 3:
return
bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
bucket["score"] += weight
if site_name:
bucket["sites"].add(site_name)
changed = 0
with get_connection() as conn:
with conn.cursor() as cur:
for site in sites:
fetched = _fetch_page(site["url"])
if not fetched:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
)
continue
final_url, title, text, html = fetched
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
old_hash = site.get("last_hash")
if old_hash and old_hash != new_hash:
cur.execute(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site["id"], old_hash, new_hash),
)
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
)
changed += 1
else:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "OK", f"Crawl OK — {title}"),
)
cur.execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(new_hash, title, site["id"]),
)
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
for page in pages:
site_name = page.get("site_name") or str(page.get("site_id") or "")
for tok in _tokens(page.get("title") or "", min_len=3):
bump_term(tok, site_name, 3)
for h in page.get("headings") or []:
hnorm = re.sub(r"\s+", " ", h.strip())[:80]
if len(hnorm) < 3:
continue
hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
hc["count"] += 1
hc["sites"].add(site_name)
for tok in _tokens(h, min_len=3):
bump_term(tok, site_name, 4)
for tok in _tokens(page.get("content_read") or ""):
bump_term(tok, site_name, 1)
for link in page.get("links_sample") or []:
for tok in _tokens(link.get("label") or "", min_len=3):
bump_term(tok, site_name, 2)
hype_terms: list[dict[str, Any]] = []
for term, data in term_scores.items():
if data["score"] < 4:
continue
sites_list = sorted(data["sites"])
hype_terms.append(
{
"term": term,
"score": data["score"],
"site_count": len(sites_list),
"sites": sites_list[:5],
"cross_site": len(sites_list) >= 2,
}
)
hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
hype_terms = hype_terms[:40]
heading_trends = []
for _key, data in heading_counts.items():
if data["count"] < 1:
continue
heading_trends.append(
{
"label": data["label"],
"count": data["count"],
"sites": sorted(data["sites"])[:6],
"cross_site": len(data["sites"]) >= 2,
}
)
heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
heading_trends = heading_trends[:25]
top_term_set = {t["term"] for t in hype_terms[:15]}
food_signals = frozenset(
"halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
"groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
"supermarkt retail assortiment prijs private label merk".split()
)
for page in pages:
signals: list[str] = []
wc = int(page.get("word_count") or 0)
sid = page.get("site_id")
if wc >= 1500:
signals.append("Rijke pagina — veel te analyseren")
elif wc >= 400:
signals.append("Normale pagina-dichtheid")
if sid in changed_site_ids:
signals.append("Recent gewijzigd — mogelijke hype/shift")
page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
matched_hype = [t for t in top_term_set if t in page_terms]
food_hits = [t for t in page_terms if t in food_signals]
for t in matched_hype[:4]:
signals.append(f"Trend-term: {t}")
for t in food_hits[:3]:
if f"Trend-term: {t}" not in signals:
signals.append(f"Food-signaal: {t}")
if page.get("meta_description"):
signals.append("SEO meta beschikbaar")
page["signals"] = signals[:8]
page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
page["recently_changed"] = sid in changed_site_ids
pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
total_words = sum(int(p.get("word_count") or 0) for p in pages)
return {
"summary": {
"pages": len(pages),
"total_words": total_words,
"themes_detected": len(hype_terms),
"headings_unique": len(heading_trends),
"changes_7d": len(recent_changes),
"query": q or None,
},
"hype_terms": hype_terms,
"heading_trends": heading_trends,
"recent_changes": recent_changes[:12],
"pages": pages,
}
+2 -2
View File
@@ -13,10 +13,10 @@ async def generate(prompt: str, system: str | None = None, timeout: float = 300.
return await chat_messages(messages, timeout=timeout)
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0, model: str | None = None) -> str:
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
payload = {
"model": settings.OLLAMA_MODEL,
"model": model or settings.OLLAMA_MODEL,
"messages": messages,
"think": False,
"stream": False,
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.4
python-multipart==0.0.12
psycopg2-binary==2.9.9
httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
openpyxl>=3.1.0
tweepy==4.15.0
+424
View File
@@ -0,0 +1,424 @@
"""Revenue Cockpit — DB operations, import, tracking, agent tasks."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services import agent_integration
from app.services.excel_import import DEFAULT_FILE, DEFAULT_SHEET, parse_revenue_sheet
def fetch_excel_parse(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
result = parse_revenue_sheet(path, sheet)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "Excel parse failed")
return result
async def fetch_excel_parse_async(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
return fetch_excel_parse(path, sheet)
def _ser(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _log_change(entity_type: str, entity_id: int, field: str, old: Any, new: Any, by: str = "ceo") -> None:
execute(
"""
INSERT INTO revenue_change_log (entity_type, entity_id, field_name, old_value, new_value, changed_by)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(entity_type, entity_id, field, str(old) if old is not None else None, str(new) if new is not None else None, by),
)
def import_from_parsed(parsed: dict[str, Any], imported_by: str = "ceo", replace: bool = True) -> dict[str, Any]:
if replace:
execute("DELETE FROM revenue_objectives WHERE project_id IN (SELECT id FROM revenue_projects)")
execute("DELETE FROM revenue_projects")
execute("UPDATE revenue_cockpit_goals SET is_active = FALSE WHERE is_active = TRUE")
goals = parsed.get("goals") or {}
g = fetch_one(
"""
INSERT INTO revenue_cockpit_goals (vision_text, horizon_text, mid_text, tagline, is_active)
VALUES (%s, %s, %s, %s, TRUE)
RETURNING *
""",
(
goals.get("vision_text", ""),
goals.get("horizon_text", ""),
goals.get("mid_text", ""),
goals.get("tagline", ""),
),
)
count = 0
for p in parsed.get("projects") or []:
row = fetch_one(
"""
INSERT INTO revenue_projects
(name, category, margin_month, margin_year, target_revenue, next_steps, status, sort_order, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
p["name"],
p.get("category", "deal"),
p.get("margin_month"),
p.get("margin_year"),
p.get("target_revenue"),
p.get("next_steps"),
p.get("status", "active"),
p.get("sort_order", count),
json.dumps({
"source_row": p.get("source_row"),
"row_style": p.get("row_style", "white"),
"imported": True,
}),
),
)
pid = row["id"] if row else None
if pid and p.get("next_steps"):
for i, line in enumerate([ln.strip() for ln in p["next_steps"].split("\n") if ln.strip()][:5]):
execute(
"""
INSERT INTO revenue_objectives (project_id, title, description, status, sort_order)
VALUES (%s, %s, %s, 'open', %s)
""",
(pid, line[:255], p["next_steps"] if i == 0 else None, i),
)
count += 1
run = fetch_one(
"""
INSERT INTO revenue_import_runs (source_file, sheet_name, rows_imported, goals_imported, imported_by, metadata)
VALUES (%s, %s, %s, TRUE, %s, %s::jsonb)
RETURNING *
""",
(
parsed.get("source_file", ""),
parsed.get("sheet_name", ""),
count,
imported_by,
json.dumps({"parsed_at": parsed.get("parsed_at"), "file_mtime": parsed.get("file_mtime")}),
),
)
take_snapshot()
return {"ok": True, "projects_imported": count, "goals": _ser(g), "import_run": _ser(run)}
def get_active_goals() -> dict[str, Any] | None:
return _ser(fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1"))
def update_goals(data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1")
if not old:
return None
fields = ("vision_text", "horizon_text", "mid_text", "tagline")
sets, params = [], []
for k in fields:
if k in data:
sets.append(f"{k} = %s")
params.append(data[k])
if str(old.get(k)) != str(data[k]):
_log_change("goals", old["id"], k, old.get(k), data[k], changed_by)
if not sets:
return get_active_goals()
params.append(old["id"])
execute(
f"UPDATE revenue_cockpit_goals SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s",
tuple(params),
)
return get_active_goals()
def list_projects(status: str | None = None) -> list[dict[str, Any]]:
clauses, params = [], []
if status:
clauses.append("status = %s")
params.append(status)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""
SELECT p.*,
(SELECT COUNT(*) FROM revenue_objectives o WHERE o.project_id = p.id AND o.status = 'open') AS open_objectives,
(SELECT COUNT(*) FROM agent_tasks t WHERE t.revenue_project_id = p.id AND t.status NOT IN ('completed','cancelled')) AS open_tasks
FROM revenue_projects p
{where}
ORDER BY p.sort_order ASC, p.name ASC
""",
tuple(params) if params else None,
)
return [_ser({**r, "row_style": _infer_row_style(r.get("name"), r.get("margin_month"), r.get("metadata"))}) for r in rows]
def get_project(project_id: int) -> dict[str, Any] | None:
p = _ser(fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,)))
if not p:
return None
p["objectives"] = [_ser(r) for r in fetch_all(
"SELECT * FROM revenue_objectives WHERE project_id = %s ORDER BY sort_order, id",
(project_id,),
)]
p["tasks"] = [_ser(r) for r in fetch_all(
"""
SELECT * FROM agent_tasks WHERE revenue_project_id = %s
ORDER BY created_at DESC LIMIT 50
""",
(project_id,),
)]
p["history"] = [_ser(r) for r in fetch_all(
"""
SELECT * FROM revenue_change_log
WHERE (entity_type = 'project' AND entity_id = %s)
OR (entity_type = 'objective' AND entity_id IN (
SELECT id FROM revenue_objectives WHERE project_id = %s))
ORDER BY created_at DESC LIMIT 30
""",
(project_id, project_id),
)]
p["row_style"] = _infer_row_style(p.get("name"), p.get("margin_month"), p.get("metadata"))
return p
def set_project_row_style(project_id: int, row_style: str) -> None:
old = fetch_one("SELECT metadata FROM revenue_projects WHERE id = %s", (project_id,))
meta = dict(old.get("metadata") or {}) if old else {}
meta["row_style"] = row_style
execute(
"UPDATE revenue_projects SET metadata = %s::jsonb, updated_at = NOW() WHERE id = %s",
(json.dumps(meta), project_id),
)
def update_project(project_id: int, data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,))
if not old:
return None
fields = {
"name": data.get("name"),
"category": data.get("category"),
"margin_month": data.get("margin_month"),
"margin_year": data.get("margin_year"),
"target_revenue": data.get("target_revenue"),
"next_steps": data.get("next_steps"),
"status": data.get("status"),
"priority": data.get("priority"),
}
sets, params = [], []
for k, v in fields.items():
if k in data:
sets.append(f"{k} = %s")
params.append(v)
if str(old.get(k)) != str(v):
_log_change("project", project_id, k, old.get(k), v, changed_by)
if not sets:
return get_project(project_id)
params.append(project_id)
execute(f"UPDATE revenue_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
return get_project(project_id)
def _infer_row_style(name: str, margin_month: Any, metadata: Any) -> str:
if isinstance(metadata, dict) and metadata.get("row_style"):
return str(metadata["row_style"])
n = (name or "").lower()
if "foodlinkk food marketing" in n or "total earnings" in n or "loonkosten aissa" in n:
return "yellow"
if "foodservice" in n or n.strip() == "dirk":
return "orange"
if margin_month is not None and margin_month > 0:
return "green"
if any(x in n for x in ("plus", "vomar", "hoogvliet", "deka", "spar", "doner palace", "zakat")):
return "red"
return "white"
def create_objective(project_id: int, title: str, description: str | None = None, priority: str = "normal") -> dict[str, Any]:
row = fetch_one(
"""
INSERT INTO revenue_objectives (project_id, title, description, priority)
VALUES (%s, %s, %s, %s)
RETURNING *
""",
(project_id, title.strip(), description, priority),
)
_log_change("objective", row["id"], "created", None, title, "ceo")
return _ser(row) or {}
def update_objective(objective_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
old = fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,))
if not old:
return None
for k in ("title", "description", "status", "priority", "due_date"):
if k in data:
execute(
f"UPDATE revenue_objectives SET {k} = %s, updated_at = NOW() WHERE id = %s",
(data[k], objective_id),
)
_log_change("objective", objective_id, k, old.get(k), data[k])
return _ser(fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,)))
def dashboard_stats() -> dict[str, Any]:
goals = get_active_goals()
totals = fetch_one(
"""
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active_projects,
COALESCE(SUM(margin_year) FILTER (WHERE status = 'active'), 0) AS total_margin_year,
COALESCE(SUM(margin_month) FILTER (WHERE status = 'active'), 0) AS total_margin_month,
COUNT(*) FILTER (WHERE category = 'deal' AND status = 'active') AS active_deals
FROM revenue_projects
"""
) or {}
open_obj = fetch_one("SELECT COUNT(*) AS n FROM revenue_objectives WHERE status = 'open'") or {"n": 0}
open_tasks = fetch_one(
"SELECT COUNT(*) AS n FROM agent_tasks WHERE status NOT IN ('completed','cancelled') AND revenue_project_id IS NOT NULL"
) or {"n": 0}
prev = fetch_one(
"SELECT * FROM revenue_snapshots WHERE snapshot_date < CURRENT_DATE ORDER BY snapshot_date DESC LIMIT 1"
)
return {
"goals": goals,
"active_projects": int(totals.get("active_projects") or 0),
"active_deals": int(totals.get("active_deals") or 0),
"total_margin_year": float(totals.get("total_margin_year") or 0),
"total_margin_month": float(totals.get("total_margin_month") or 0),
"open_objectives": int(open_obj.get("n") or 0),
"open_agent_tasks": int(open_tasks.get("n") or 0),
"previous_snapshot": _ser(prev),
}
def take_snapshot() -> dict[str, Any]:
stats = dashboard_stats()
row = fetch_one(
"""
INSERT INTO revenue_snapshots
(snapshot_date, total_margin_year, total_margin_month, active_projects, open_objectives, open_agent_tasks, payload)
VALUES (CURRENT_DATE, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (snapshot_date) DO UPDATE SET
total_margin_year = EXCLUDED.total_margin_year,
total_margin_month = EXCLUDED.total_margin_month,
active_projects = EXCLUDED.active_projects,
open_objectives = EXCLUDED.open_objectives,
open_agent_tasks = EXCLUDED.open_agent_tasks,
payload = EXCLUDED.payload,
created_at = NOW()
RETURNING *
""",
(
stats["total_margin_year"],
stats["total_margin_month"],
stats["active_projects"],
stats["open_objectives"],
stats["open_agent_tasks"],
json.dumps({"goals_id": (stats.get("goals") or {}).get("id")}),
),
)
return _ser(row) or {}
def list_snapshots(limit: int = 90) -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM revenue_snapshots ORDER BY snapshot_date DESC LIMIT %s",
(max(1, min(limit, 365)),),
)
return [_ser(r) for r in rows]
def assign_agent_task(
project_id: int,
agent_name: str,
title: str,
description: str | None = None,
objective_id: int | None = None,
priority: str = "normal",
delegate_herman: bool = True,
) -> dict[str, Any]:
project = fetch_one("SELECT name FROM revenue_projects WHERE id = %s", (project_id,))
if not project:
raise ValueError("Project not found")
agent = agent_name.strip().lower()
desc = description or ""
row = fetch_one(
"""
INSERT INTO agent_tasks
(agent_name, title, description, status, priority, assigned_by, revenue_project_id, revenue_objective_id, source)
VALUES (%s, %s, %s, 'pending', %s, 'ceo', %s, %s, 'revenue_cockpit')
RETURNING *
""",
(agent, title.strip(), desc, priority, project_id, objective_id),
)
task = _ser(row) or {}
body = f"Revenue Cockpit · {project['name']}: {title}"
if desc:
body += f"\n{desc}"
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata, related_table, related_id)
VALUES (%s, 'revenue_cockpit', 'task_assigned', %s, %s, 'pending', 'revenue_cockpit', %s::jsonb, 'agent_tasks', %s)
""",
(
agent,
title.strip(),
body,
json.dumps({"project_id": project_id, "objective_id": objective_id, "task_id": task.get("id")}),
task.get("id"),
),
)
except Exception:
pass
if delegate_herman and agent != "herman":
try:
agent_integration.create_handoff(
"ceo",
agent,
handoff_type="task",
payload={"task_id": task.get("id"), "project_id": project_id, "title": title},
status="pending",
)
except Exception:
pass
_log_change("project", project_id, "agent_task", None, title, "ceo")
return task
def list_agent_tasks(limit: int = 50) -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT t.*, p.name AS project_name
FROM agent_tasks t
LEFT JOIN revenue_projects p ON p.id = t.revenue_project_id
WHERE t.revenue_project_id IS NOT NULL
ORDER BY t.created_at DESC
LIMIT %s
""",
(max(1, min(limit, 200)),),
)
return [_ser(r) for r in rows]
async def delegate_via_herman(project_id: int, message: str) -> dict[str, Any]:
from app.services import herman as herman_service
project = get_project(project_id)
if not project:
raise ValueError("Project not found")
prompt = f"[Revenue Cockpit · {project['name']}] {message}"
result = await herman_service.chat(prompt)
return {"ok": True, "result": result, "project_id": project_id}
@@ -0,0 +1,594 @@
"""Voice/browser/Telegram command router: export intel, webbuilder/agy, bevestiging + UI."""
from __future__ import annotations
import re
import time
import uuid
from typing import Any
import httpx
from app.config import settings
from app.services import webbuilder_agent
SESSION_TTL_SEC = 3600
_pending: dict[str, dict[str, Any]] = {}
VOICE_CHANNELS = frozenset({"voice", "browser", "telegram"})
LOCATION_ALIASES: dict[str, dict[str, str]] = {
"dubai": {"country": "AE", "q": "Dubai", "label": "Dubai (VAE)"},
"abu dhabi": {"country": "AE", "q": "Abu Dhabi", "label": "Abu Dhabi (VAE)"},
"sharjah": {"country": "AE", "q": "Sharjah", "label": "Sharjah (VAE)"},
"vae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
"uae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
"emiraten": {"country": "AE", "q": "", "label": "VAE"},
"saudi": {"country": "SA", "q": "", "label": "Saoedi-Arabië"},
"riyadh": {"country": "SA", "q": "Riyadh", "label": "Riyadh (SA)"},
"jeddah": {"country": "SA", "q": "Jeddah", "label": "Jeddah (SA)"},
"qatar": {"country": "QA", "q": "", "label": "Qatar"},
"doha": {"country": "QA", "q": "Doha", "label": "Doha (Qatar)"},
"kuwait": {"country": "KW", "q": "", "label": "Koeweit"},
"nederland": {"country": "NL", "q": "", "label": "Nederland"},
"amsterdam": {"country": "NL", "q": "Amsterdam", "label": "Amsterdam"},
"rotterdam": {"country": "NL", "q": "Rotterdam", "label": "Rotterdam"},
"belgië": {"country": "BE", "q": "", "label": "België"},
"belgie": {"country": "BE", "q": "", "label": "België"},
"antwerpen": {"country": "BE", "q": "Antwerp", "label": "Antwerpen"},
"duitsland": {"country": "DE", "q": "", "label": "Duitsland"},
"berlijn": {"country": "DE", "q": "Berlin", "label": "Berlijn"},
"frankfurt": {"country": "DE", "q": "Frankfurt", "label": "Frankfurt"},
"turkije": {"country": "TR", "q": "", "label": "Turkije"},
"istanbul": {"country": "TR", "q": "Istanbul", "label": "Istanbul"},
"marokko": {"country": "MA", "q": "", "label": "Marokko"},
"casablanca": {"country": "MA", "q": "Casablanca", "label": "Casablanca"},
}
ENTITY_ALIASES: dict[str, str] = {
"distri": "distributor,wholesaler,importer,logistics",
"distributeur": "distributor,wholesaler,importer,logistics",
"distributeurs": "distributor,wholesaler,importer,logistics",
"groothandel": "wholesaler,distributor",
"groothandels": "wholesaler,distributor",
"importeur": "importer",
"importeurs": "importer",
"logistiek": "logistics",
"restaurant": "restaurant",
"restaurants": "restaurant",
"cateraar": "caterer",
"cateraars": "caterer",
"slager": "butcher",
"slagers": "butcher",
"döner": "doner",
"doner": "doner",
}
SEARCH_TRIGGERS = (
"zoek", "opzoeken", "vind", "zoeken", "toon", "laat zien", "lijst",
"geef me", "haal op", "export intel", "wereldexport",
)
YES_RE = re.compile(
r"^(ja|jawel|jep|yep|ok|oke|oké|okay|klopt|bevestig|doe maar|graag|precies|goed|akkoord|start|uitvoeren|doorgaan)\b",
re.I,
)
NO_RE = re.compile(r"^(nee|neen|stop|annuleer|niet|cancel|laat maar|wacht)\b", re.I)
def _cleanup_sessions() -> None:
now = time.time()
dead = [k for k, v in _pending.items() if now - float(v.get("_ts", 0)) > SESSION_TTL_SEC]
for k in dead:
_pending.pop(k, None)
def is_confirmation_yes(text: str) -> bool:
t = (text or "").strip()
return bool(t and YES_RE.search(t))
def is_confirmation_no(text: str) -> bool:
t = (text or "").strip()
return bool(t and NO_RE.search(t))
def wants_export_search(text: str) -> bool:
t = (text or "").lower()
if not any(k in t for k in SEARCH_TRIGGERS):
return False
has_loc = any(alias in t for alias in LOCATION_ALIASES)
has_ent = any(alias in t for alias in ENTITY_ALIASES)
return has_loc or has_ent
def _detect_location(text: str) -> dict[str, str] | None:
t = text.lower()
best: tuple[int, dict[str, str]] | None = None
for alias, loc in LOCATION_ALIASES.items():
if alias in t:
score = len(alias)
if best is None or score > best[0]:
best = (score, loc)
return best[1] if best else None
def _detect_entity_types(text: str) -> str:
t = text.lower()
found: list[str] = []
for alias, types in ENTITY_ALIASES.items():
if alias in t:
for et in types.split(","):
if et not in found:
found.append(et)
if not found and any(w in t for w in ("distri", "distributeur", "groothandel", "b2b", "leverancier")):
return "distributor,wholesaler,importer,logistics"
return ",".join(found) if found else "distributor,wholesaler,importer,logistics"
def _entity_label(entity_types: str) -> str:
labels = {
"distributor": "distributeurs",
"wholesaler": "groothandels",
"importer": "importeurs",
"logistics": "logistiek",
"restaurant": "restaurants",
"caterer": "cateraars",
"butcher": "slagers",
"doner": "dönerzaken",
}
parts = [labels.get(x.strip(), x.strip()) for x in entity_types.split(",") if x.strip()]
return ", ".join(parts) if parts else "bedrijven"
def parse_export_search(text: str) -> dict[str, Any]:
loc = _detect_location(text)
entity_types = _detect_entity_types(text)
missing: list[str] = []
if not loc:
missing.append("locatie")
params: dict[str, Any] = {
"country": (loc or {}).get("country", ""),
"q": (loc or {}).get("q", ""),
"entity_types": entity_types,
"limit": 50,
}
label_loc = (loc or {}).get("label", "")
return {
"params": params,
"location_label": label_loc,
"entity_label": _entity_label(entity_types),
"missing": missing,
"incomplete": bool(missing),
}
def store_pending(session_id: str, action: dict[str, Any]) -> str:
_cleanup_sessions()
action_id = str(uuid.uuid4())[:12]
action = dict(action)
action["id"] = action_id
action["_ts"] = time.time()
_pending[session_id] = action
return action_id
def get_pending(session_id: str) -> dict[str, Any] | None:
_cleanup_sessions()
row = _pending.get(session_id)
if not row:
return None
if time.time() - float(row.get("_ts", 0)) > SESSION_TTL_SEC:
_pending.pop(session_id, None)
return None
return row
def clear_pending(session_id: str) -> None:
_pending.pop(session_id, None)
def build_confirmation_question(parsed: dict[str, Any]) -> str:
loc = parsed.get("location_label") or "de geselecteerde regio"
ent = parsed.get("entity_label") or "bedrijven"
return (
f"Ik ga **{ent}** in **{loc}** voor je opzoeken in Export Intel.\n\n"
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren, "
"of geef aan wat ik moet aanpassen (bijv. alleen distributeurs, of een andere stad)."
)
def build_clarification_question(parsed: dict[str, Any]) -> str:
missing = parsed.get("missing") or []
if "locatie" in missing:
return (
"In welke **stad of welk land** wil je zoeken? "
"Bijvoorbeeld: Dubai, VAE, Nederland, Frankfurt…"
)
return "Kun je iets specifieker zijn over wat je zoekt en waar?"
async def fetch_export_entities(params: dict[str, Any]) -> dict[str, Any]:
query = {k: v for k, v in params.items() if v not in (None, "")}
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/entities"
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(url, params=query)
resp.raise_for_status()
return resp.json()
def _open_url(params: dict[str, Any]) -> str:
qs = []
if params.get("country"):
qs.append(f"country={params['country']}")
if params.get("q"):
qs.append(f"q={params['q']}")
qs.append("tab=distributors")
return "/export-intel?" + "&".join(qs)
async def execute_search_action(action: dict[str, Any]) -> dict[str, Any]:
params = action.get("params") or {}
data = await fetch_export_entities(params)
items = data.get("items") or []
total = int(data.get("total") or len(items))
loc = action.get("location_label") or params.get("q") or params.get("country") or "markt"
ent = action.get("entity_label") or "bedrijven"
title = f"{ent.title()} — {loc}"
if not items:
reply = (
f"Ik heb gezocht maar vond **geen** {ent} in {loc}. "
"Wil je dat ik een sync start of een bredere regio probeer?"
)
else:
reply = (
f"Gevonden: **{total}** {ent} in {loc}. "
f"Ik toon de eerste {min(len(items), 50)} in het resultatenvenster."
)
return {
"agent": "sourcing",
"agent_label": "Export Intel → Herman",
"reply": reply,
"delegated_agents": ["sourcing", "export_intel"],
"routing_reason": f"Export Intel zoekopdracht: {title}",
"agent_steps": [
{"agent": "export_intel", "status": "done", "message": f"{total} resultaten"},
{"agent": "sourcing", "status": "delegated", "message": "Marktdata opgehaald"},
],
"needs_confirmation": False,
"ui_actions": [
{
"type": "show_export_results",
"title": title,
"entities": items[:50],
"total": total,
"params": params,
"open_url": _open_url(params),
}
],
}
def _pending_summary(action: dict[str, Any]) -> dict[str, Any]:
atype = action.get("type", "")
base = {"id": action.get("id"), "type": atype}
if atype == "export_intel_search":
base["location_label"] = action.get("location_label")
base["entity_label"] = action.get("entity_label")
base["params"] = action.get("params")
elif atype == "webbuilder_build":
base["project"] = action.get("project")
base["entity_label"] = f"website {action.get('project', '')}"
base["location_label"] = "Agy · Antigravity"
return base
def build_webbuilder_confirmation(project: str, raw: str) -> str:
snippet = (raw or "")[:240]
return (
f"Ik stuur **Agy (Antigravity)** op Hermes aan om een website te bouwen.\n\n"
f"**Project:** {project}\n"
f"**Opdracht:** {snippet}{'…' if len(raw or '') > 240 else ''}\n\n"
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren."
)
async def execute_webbuilder_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
raw = action.get("raw_message") or ""
project = action.get("project") or webbuilder_agent.extract_project_name(raw)
try:
outcome = await webbuilder_agent.generate_from_message(raw, channel=channel, wait=False)
except Exception as exc:
return {
"agent": "webbuilder",
"agent_label": "Web Builder",
"reply": f"Agy kon niet starten op Hermes: {exc}\n\nControleer VM107 webbuilder-api (:8798).",
"delegated_agents": ["webbuilder"],
"needs_confirmation": False,
}
project = outcome.get("project") or project
preview = outcome.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
nas_path = outcome.get("nas_path") or ""
reply = (
f"Agy is gestart voor **{project}**.\n"
f"Preview (na build): {preview}\n"
f"NAS: {nas_path}\n"
"Volg live: Agents → Terminals → Web Builder."
)
return {
"agent": "webbuilder",
"agent_label": "Agy / Web Builder → Herman",
"reply": reply,
"delegated_agents": ["webbuilder"],
"routing_reason": "Website via Antigravity CLI (agy) op Hermes VM107",
"agent_steps": [
{"agent": "webbuilder", "status": "running", "message": f"agy bouwt {project}"},
{"agent": "herman", "status": "delegated", "message": "Voice → build gestart"},
],
"webbuilder_project": project,
"webbuilder_preview_url": preview,
"needs_confirmation": False,
"ui_actions": [
{
"type": "open_webbuilder_build",
"title": f"Website build — {project}",
"project": project,
"preview_url": preview,
"nas_path": nas_path,
"agents_url": "/agents",
"status_hint": "Build duurt enkele minuten — preview opent na afloop",
}
],
}
async def execute_pending_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
atype = action.get("type")
if atype == "webbuilder_build":
return await execute_webbuilder_action(action, channel=channel)
return await execute_search_action(action)
def _confirmation_reminder(pending: dict[str, Any]) -> str:
if pending.get("type") == "webbuilder_build":
return build_webbuilder_confirmation(pending.get("project", "website"), pending.get("raw_message", ""))
return build_confirmation_question(pending)
async def handle_voice_command(
message: str,
session_id: str | None = None,
confirm_action_id: str | None = None,
channel: str = "voice",
) -> dict[str, Any] | None:
"""Unified voice/browser router: bevestiging + export + webbuilder/agy."""
sid = (session_id or "").strip() or "default"
text = (message or "").strip()
if not text:
return None
pending = get_pending(sid)
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
clear_pending(sid)
return await execute_pending_action(pending, channel=channel)
if pending:
if is_confirmation_yes(text):
clear_pending(sid)
return await execute_pending_action(pending, channel=channel)
if is_confirmation_no(text):
clear_pending(sid)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
"needs_confirmation": False,
}
if pending.get("awaiting") == "location" and pending.get("type") == "export_intel_search":
loc = _detect_location(text)
if loc:
merged = dict(pending)
merged["params"] = dict(pending.get("params") or {})
merged["params"]["country"] = loc["country"]
merged["params"]["q"] = loc.get("q", "")
merged["location_label"] = loc["label"]
merged.pop("awaiting", None)
merged.pop("incomplete", None)
merged.pop("missing", None)
action_id = store_pending(sid, merged)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(merged),
"needs_confirmation": True,
"pending_action": _pending_summary(merged),
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question({"missing": ["locatie"]}),
"needs_confirmation": True,
"clarification": True,
}
if webbuilder_agent.wants_website(text):
project = webbuilder_agent.extract_project_name(text)
action_id = store_pending(sid, {
"type": "webbuilder_build",
"project": project,
"raw_message": text,
})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_webbuilder_confirmation(project, text),
"needs_confirmation": True,
"pending_action": _pending_summary(get_pending(sid) or {}),
}
if wants_export_search(text):
parsed = parse_export_search(text)
if not parsed.get("incomplete"):
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": _pending_summary(get_pending(sid) or {}),
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Ik wacht nog op bevestiging.\n\n{_confirmation_reminder(pending)}\n\n"
"Zeg **ja** of **nee**."
),
"needs_confirmation": True,
"pending_action": _pending_summary(pending),
}
if webbuilder_agent.wants_website(text):
project = webbuilder_agent.extract_project_name(text)
store_pending(sid, {"type": "webbuilder_build", "project": project, "raw_message": text})
pa = get_pending(sid) or {}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_webbuilder_confirmation(project, text),
"needs_confirmation": True,
"pending_action": _pending_summary(pa),
}
return await handle_export_intent(message, session_id=session_id, confirm_action_id=confirm_action_id)
async def handle_export_intent(
message: str,
session_id: str | None = None,
confirm_action_id: str | None = None,
) -> dict[str, Any] | None:
"""Return Herman-shaped dict when export flow applies, else None."""
sid = (session_id or "").strip() or "default"
text = (message or "").strip()
if not text:
return None
pending = get_pending(sid)
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
clear_pending(sid)
return await execute_pending_action(pending)
if pending:
if is_confirmation_yes(text):
clear_pending(sid)
return await execute_pending_action(pending)
if is_confirmation_no(text):
clear_pending(sid)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
"needs_confirmation": False,
}
if pending.get("awaiting") == "location":
loc = _detect_location(text)
if loc:
merged = dict(pending)
merged["params"] = dict(pending.get("params") or {})
merged["params"]["country"] = loc["country"]
merged["params"]["q"] = loc.get("q", "")
merged["location_label"] = loc["label"]
merged.pop("awaiting", None)
merged.pop("incomplete", None)
merged.pop("missing", None)
action_id = store_pending(sid, merged)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(merged),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": merged.get("params"),
"location_label": merged.get("location_label"),
"entity_label": merged.get("entity_label"),
},
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question({"missing": ["locatie"]}),
"needs_confirmation": True,
"clarification": True,
}
if wants_export_search(text):
parsed = parse_export_search(text)
if parsed.get("incomplete"):
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question(parsed),
"needs_confirmation": True,
"clarification": True,
}
action_id = store_pending(sid, parsed)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": parsed.get("params"),
"location_label": parsed.get("location_label"),
"entity_label": parsed.get("entity_label"),
},
}
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Ik wacht nog op bevestiging: {build_confirmation_question(pending)}\n\n"
"Zeg **ja** of **nee**, of stel een nieuwe zoekopdracht."
),
"needs_confirmation": True,
"pending_action": {
"id": pending.get("id"),
"type": pending.get("type", "export_intel_search"),
"params": pending.get("params"),
"location_label": pending.get("location_label"),
"entity_label": pending.get("entity_label"),
},
}
if not wants_export_search(text):
return None
parsed = parse_export_search(text)
if parsed.get("incomplete"):
store_pending(sid, {**parsed, "type": "export_intel_search", "awaiting": "location"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_clarification_question(parsed),
"needs_confirmation": True,
"clarification": True,
}
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
return {
"agent": "herman",
"agent_label": "Herman",
"reply": build_confirmation_question(parsed),
"needs_confirmation": True,
"pending_action": {
"id": action_id,
"type": "export_intel_search",
"params": parsed.get("params"),
"location_label": parsed.get("location_label"),
"entity_label": parsed.get("entity_label"),
},
}
+267
View File
@@ -0,0 +1,267 @@
"""Web Builder agent — websites bouwen via Hermes/agy API op VM107."""
from __future__ import annotations
import os
import re
from typing import Any
import httpx
from app.config import settings
from app.services.agent_events_log import log_agent_event
HERMES_BUILD_URL = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798").rstrip("/")
HERMES_PREVIEW_BASE = os.getenv("HERMES_PREVIEW_BASE", "http://10.4.7.27:8080").rstrip("/")
WEBSITE_KEYWORDS = (
"maak website",
"maak een website",
"bouw website",
"bouw een website",
"genereer website",
"website voor",
"website bouwen",
"landingspagina",
"landing page",
"webshop",
"webpagina",
"nieuwe site",
"nieuwe website",
"/website",
"web builder",
"webbuilder",
"agy",
"antigravity",
"anti gravity",
"anti-gravity",
"site maken",
"maak een site",
"bouw een site",
"website laten maken",
"laat agy",
"via agy",
)
PROJECT_RE = re.compile(
r"(?:website|site|project)\s+(?:voor\s+|called\s+|genaamd\s+)?['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
re.I,
)
FOR_RE = re.compile(
r"\bvoor\s+['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
re.I,
)
def wants_website(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in WEBSITE_KEYWORDS)
def slugify(name: str) -> str:
s = (name or "").strip().lower()
s = re.sub(r"[^a-z0-9_-]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
return s[:64] or "website"
def extract_project_name(raw: str) -> str:
t = (raw or "").strip()
m = PROJECT_RE.search(t)
if m:
return slugify(m.group(1))
m = FOR_RE.search(t)
if m:
name = m.group(1)
name = re.split(r"\s+(?:met|with|incl|including)\b", name, maxsplit=1, flags=re.I)[0]
return slugify(name)
for token in t.split():
clean = slugify(token)
if len(clean) >= 3 and clean not in (
"maak", "bouw", "website", "site", "voor", "een", "the", "and", "met",
"simpele", "landing", "page", "pagina", "landingspagina",
):
return clean
return slugify(t[:40]) or "website"
def extract_build_prompt(raw: str, project: str) -> str:
body = (raw or "").strip()
lower = body.lower()
for k in WEBSITE_KEYWORDS:
if lower.startswith(k):
rest = body[len(k) :].strip(" :,-")
if rest:
return rest
return (
f"Bouw een complete, moderne, responsive website voor project '{project}'. "
f"Opdracht: {body}. "
"Gebruik index.html, style.css, script.js en een assets/ map. "
"Foodlinkk halal kant-en-klaar food branding waar passend."
)
async def _hermes_get(path: str, timeout: float = 30.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{HERMES_BUILD_URL}{path}")
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
return data
async def _hermes_post(path: str, payload: dict[str, Any], timeout: float = 60.0) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{HERMES_BUILD_URL}{path}", json=payload)
try:
data = resp.json()
except Exception:
data = {"ok": False, "detail": resp.text[:500]}
if resp.status_code >= 400:
data.setdefault("ok", False)
data.setdefault("detail", resp.text[:500])
return data
async def list_projects() -> dict[str, Any]:
return await _hermes_get("/api/projects")
async def get_build_status(project: str) -> dict[str, Any]:
slug = slugify(project)
return await _hermes_get(f"/api/build/{slug}/status")
async def build_from_message(raw: str, *, channel: str = "herman") -> dict[str, Any]:
project = extract_project_name(raw)
prompt = extract_build_prompt(raw, project)
log_agent_event(
"webbuilder",
"website_build_start",
f"Start build: {project}",
prompt[:4000],
channel=channel,
metadata={"project": project, "prompt": prompt[:500]},
status="running",
)
data = await _hermes_post(
"/api/build",
{"project": project, "prompt": prompt},
timeout=45.0,
)
if not data.get("ok", True) and data.get("detail"):
log_agent_event(
"webbuilder",
"website_build_error",
f"Build mislukt: {project}",
str(data.get("detail", data))[:2000],
channel=channel,
metadata={"project": project},
status="error",
)
raise RuntimeError(str(data.get("detail") or data))
job_id = data.get("job_id") or project
preview = data.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
nas_path = data.get("nas_path") or f"//10.4.7.11/share/Websites/{project}/"
log_agent_event(
"webbuilder",
"website_build_running",
f"agy bezig: {project}",
f"Job {job_id}\nPreview: {preview}\nNAS: {nas_path}",
channel=channel,
metadata={
"project": project,
"job_id": job_id,
"preview_url": preview,
"nas_path": nas_path,
"correlation_id": job_id,
},
status="running",
)
return {
"project": project,
"job_id": job_id,
"preview_url": preview,
"nas_path": nas_path,
"prompt": prompt,
"hermes_status": data.get("status", "running"),
}
async def poll_until_done(project: str, *, channel: str = "herman", timeout: float = 1800.0) -> dict[str, Any]:
"""Poll Hermes build status until completed or failed."""
import asyncio
slug = slugify(project)
elapsed = 0.0
interval = 5.0
last_log = ""
while elapsed < timeout:
st = await get_build_status(slug)
status = str(st.get("status") or "unknown")
msg = str(st.get("message") or status)
if msg != last_log:
log_agent_event(
"webbuilder",
"website_build_progress",
msg[:255],
(st.get("log_tail") or "")[:4000],
channel=channel,
metadata={"project": slug, "status": status},
status="running" if status in ("running", "queued") else status,
)
last_log = msg
if status == "completed":
files = st.get("files") or []
preview = st.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
log_agent_event(
"webbuilder",
"website_build_done",
f"Website klaar: {slug}",
f"Bestanden: {', '.join(files[:8])}\nPreview: {preview}",
channel=channel,
metadata={
"project": slug,
"preview_url": preview,
"files": files,
"for_herman": True,
"source_agent": "webbuilder",
},
status="completed",
)
return {**st, "project": slug, "preview_url": preview}
if status == "failed":
detail = str(st.get("error") or st.get("message") or "Build mislukt")
log_agent_event(
"webbuilder",
"website_build_error",
f"Build mislukt: {slug}",
detail[:2000],
channel=channel,
metadata={"project": slug},
status="error",
)
raise RuntimeError(detail)
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(f"Build timeout voor {slug} na {int(timeout)}s")
async def generate_from_message(raw: str, *, channel: str = "herman", wait: bool = True) -> dict[str, Any]:
started = await build_from_message(raw, channel=channel)
if wait:
finished = await poll_until_done(started["project"], channel=channel)
started.update(finished)
return started
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#0a0e14" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
<title>{% block title %}{{ page_title }} · Cucina{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json?v=2" />
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=4" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=3" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<script defer src="/static/js/alpine.min.js?v=1"></script>
<script defer src="/static/js/htmx.min.js?v=1"></script>
{% block extra_head %}{% endblock %}
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'persona-ceo': persona === 'ceo', 'persona-cto': persona === 'cto' }">
<div class="app-shell">
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
<div class="app-sidebar-brand">
<p class="brand-title">Cucina · Foodlinkk</p>
<p class="brand-name" x-text="persona === 'ceo' ? 'CEO Dashboard' : 'CTO Dashboard'">CEO Dashboard</p>
</div>
<div class="persona-switch" role="tablist" aria-label="Dashboard modus">
<button type="button" role="tab" class="persona-btn persona-btn-ceo"
:class="{ active: persona === 'ceo' }" @click="setPersona('ceo')"
:aria-selected="persona === 'ceo'" data-i18n="nav.persona_ceo">CEO · Business</button>
<button type="button" role="tab" class="persona-btn persona-btn-cto"
:class="{ active: persona === 'cto' }" @click="setPersona('cto')"
:aria-selected="persona === 'cto'" data-i18n="nav.persona_cto">CTO · Tech</button>
</div>
<!-- ═══ CEO · BUSINESS ═══ -->
<template x-if="persona === 'ceo'">
<div class="persona-nav">
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.ceo_home">CEO Overzicht</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.business">Business</div>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
</div>
</div>
</template>
<!-- ═══ CTO · TECH ═══ -->
<template x-if="persona === 'cto'">
<div class="persona-nav">
<div class="app-sidebar-group">
<a href="/cto" class="nav-pill nav-pill-ops {% if request.url.path == '/cto' %}active{% endif %}" data-i18n="nav.cto_home">CTO Overzicht</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.devops">DevOps</div>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.tools_api">Tools API</a>
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="nav-pill nav-pill-analytics" data-i18n="nav.grafana">Grafana</a>
</div>
</div>
</template>
</aside>
<div class="mobile-nav-overlay" x-show="navOpen" @click="navOpen=false" x-cloak></div>
<div class="app-main">
<header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen">☰</button>
<span class="page-tip">
<span class="persona-badge" :class="persona === 'ceo' ? 'persona-badge-ceo' : 'persona-badge-cto'" x-text="persona === 'ceo' ? 'CEO' : 'CTO'"></span>
Cucina · Foodlinkk
</span>
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
<option value="nl">NL</option>
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
</div>
</div>
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/i18n.js?v=4"></script>
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script>
window.FoodlinkkPersona = (function () {
var CTO_PREFIXES = ['/cto', '/ops', '/agents', '/browser', '/monitor', '/studio', '/voice', '/settings'];
function isCtoPath(path) {
return CTO_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
}
function detect(path) {
var stored = localStorage.getItem('foodlinkk-persona');
if (stored === 'ceo' || stored === 'cto') return stored;
return isCtoPath(path) ? 'cto' : 'ceo';
}
return { isCtoPath: isCtoPath, detect: detect };
})();
function appShell() {
return {
locale: 'nl',
persona: 'ceo',
drawerOpen: false,
navOpen: false,
showInstall: false,
_deferredPrompt: null,
clockDate: '—',
clockTime: '—',
_offset: 0,
async initApp() {
this.persona = window.FoodlinkkPersona.detect(location.pathname);
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock();
this.initPwa();
},
setPersona(p) {
if (p === this.persona) return;
this.persona = p;
localStorage.setItem('foodlinkk-persona', p);
var path = location.pathname;
var onCto = window.FoodlinkkPersona.isCtoPath(path);
if (p === 'ceo' && onCto) { location.href = '/'; return; }
if (p === 'cto' && !onCto) { location.href = '/cto'; return; }
},
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
},
async initClock() {
try {
const r = await fetch('/api/server-time').then(x => x.json());
this._offset = new Date(r.utc).getTime() - Date.now();
} catch (e) { this._offset = 0; }
const tick = () => {
const d = new Date(Date.now() + this._offset);
this.clockDate = d.toLocaleDateString('nl-NL', { timeZone: 'Europe/Amsterdam', weekday: 'short', day: 'numeric', month: 'short' });
this.clockTime = d.toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
tick();
setInterval(tick, 1000);
},
initPwa() {
if (window.matchMedia('(display-mode: standalone)').matches) return;
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.showInstall = !localStorage.getItem('pwa-dismiss');
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this._deferredPrompt = e;
this.showInstall = true;
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
}
},
async installPwa() {
if (this._deferredPrompt) {
this._deferredPrompt.prompt();
await this._deferredPrompt.userChoice;
this._deferredPrompt = null;
this.showInstall = false;
return;
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
} else {
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
}
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
}
};
}
</script>
<script>
window.addEventListener('load', function () {
setTimeout(function () {
if (window.Alpine) return;
document.querySelectorAll('[x-cloak]').forEach(function (el) { el.removeAttribute('x-cloak'); });
document.body.classList.add('alpine-missing');
console.error('Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).');
}, 2500);
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo" x-data="ctoDashboard()" x-init="init()">
<div class="herman-hero">
<h1>CTO Dashboard <span class="live-badge">Live</span></h1>
<p class="hero-sub">Platform · agents · infra · DevOps · goedkeuringen</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/agents?tab=approvals" class="beurs-mini-chip">✓ Goedkeuringen</a>
<a href="/browser" class="beurs-mini-chip">🌐 Browser agent</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="beurs-mini-chip">📈 Grafana</a>
</div>
</div>
<section class="panel herman-briefing">
<header class="herman-briefing-header">
<div>
<h2>Platform status</h2>
<div class="briefing-meta" x-text="opsMeta">Laden…</div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
<a href="/ops" class="btn btn-pulse-green">Volledige IT Ops →</a>
</div>
</header>
<div class="hm-neo-kpi-row" style="margin-bottom:1.25rem">
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Health</div>
<div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Online nodes</div>
<div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Services</div>
<div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Open goedkeuringen</div>
<div class="hm-neo-kpi-value" x-text="pendingApprovals"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Agent events (24u)</div>
<div class="hm-neo-kpi-value" x-text="agentEvents24h"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Browser sessies (24u)</div>
<div class="hm-neo-kpi-value">{{ kpis.browser_sessions_24h }}</div>
</div>
</div>
<div class="hm-dash-two-col">
<section class="hm-dash-section" data-widget="approvals">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen</h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>.</p>
<div id="cto-approvals-list"></div>
</div>
</section>
<section class="hm-dash-section">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Maintenance</h3>
<template x-if="!maintenance.length">
<p class="empty-state">Geen open maintenance items 🎉</p>
</template>
<div class="ops-maint-list">
<template x-for="n in maintenance.slice(0, 6)" :key="n.id">
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
<strong x-text="n.title"></strong>
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
</div>
</template>
</div>
<p class="page-tip" style="margin-top:0.75rem"><a href="/ops">Alle maintenance →</a></p>
</section>
</div>
<div class="hub-grid hm-dash-feed" style="margin-top:1.25rem">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="cto-feed-badge">Live</span></h2>
<div id="cto-agent-feed">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Browser sessies</h2>
{% if browser_sessions %}
{% for s in browser_sessions[:6] %}
<div class="hub-feed-item">
<span>{{ s.created_at[11:16] if s.created_at else '' }}</span>
<span class="badge badge-agent-browser">browser</span>
{{ s.title or s.url or s.task }}
</div>
{% endfor %}
{% else %}
<p class="empty-state">Geen recente browser-sessies.</p>
{% endif %}
<p class="page-tip" style="margin-top:0.75rem"><a href="/browser">Browser agent →</a></p>
</section>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
function ctoDashboard() {
return {
loading: false,
summary: {},
meta: {},
maintenance: [],
opsMeta: 'Laden…',
pendingApprovals: {{ kpis.pending_approvals }},
agentEvents24h: 0,
_pollStop: null,
_wsStop: null,
async init() {
localStorage.setItem('foodlinkk-persona', 'cto');
await this.refresh(false);
await this.loadApprovals();
this._pollStop = CockpitLive.startPolling(() => { this.refresh(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('cto-agent-feed'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('cto-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
async refresh(toast) {
this.loading = true;
try {
const [status, maint, analytics] = await Promise.all([
fetch('/api/ops/status').then(x => x.json()).catch(() => ({})),
fetch('/api/ops/maintenance?limit=12').then(x => x.json()).catch(() => ({ items: [] })),
fetch('/analytics/api/data').then(x => x.json()).catch(() => ({})),
]);
this.summary = status || { counts: {} };
this.meta = (status.topology && status.topology.meta) || status.sources || {};
this.maintenance = (maint.items || []).filter(n => !n.resolved);
this.opsMeta = 'Sync: ' + new Date().toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam' });
if (analytics.kpis) {
this.pendingApprovals = analytics.kpis.pending_approvals ?? this.pendingApprovals;
this.agentEvents24h = analytics.kpis.agent_events ?? 0;
}
if (toast !== false) Cockpit.toast('Platform data bijgewerkt', 'success');
} catch (e) {
if (toast !== false) Cockpit.toast(e.message || 'Fout', 'error');
}
this.loading = false;
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('cto-approvals-list');
if (!el) return;
const items = r.items || [];
this.pendingApprovals = items.length;
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
}
};
}
</script>
{% endblock %}
+541
View File
@@ -0,0 +1,541 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero">
<h1>CEO Dashboard · Cucina <span class="live-badge">Live</span></h1>
<p class="hero-sub">Business overzicht · retail · deals · briefing · marketing</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="/clients" class="beurs-mini-chip">👥 Klanten</a>
<a href="/deals" class="beurs-mini-chip">💼 Deals</a>
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="/retail" class="beurs-mini-chip">🛒 Retail</a>
<a href="/analytics" class="beurs-mini-chip">📊 Analytics</a>
</div>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
<nav class="herman-dash-tabs" aria-label="Herman weergaven">
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'overview' }" @click="setHermanTab('overview')">
<span aria-hidden="true">📊</span> Overzicht
</button>
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'rss' }" @click="setHermanTab('rss')">
<span aria-hidden="true">📰</span> RSS Feeds
<span class="herman-dash-tab-badge" x-show="rssTotal > 0" x-text="rssTotal"></span>
</button>
</nav>
<header class="herman-briefing-header">
<div>
<h2>Dagelijkse briefing</h2>
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? '✓ Klaar' : '⚙ Dashboard instellen'"></button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
<a href="/analytics" class="btn">Analytics</a>
</div>
</header>
<div id="hm-live-bar" class="hm-live-bar" x-show="loading || liveLog.length" x-cloak :class="loading ? 'hm-live-bar-active' : 'hm-live-bar-done'">
<div class="hm-live-bar-head">
<span class="hm-live-dot"></span>
<strong class="hm-live-title" x-text="loading ? loadingMsg : 'Dagrapport voltooid'"></strong>
<span class="hm-live-progress" x-show="loading" x-text="liveProgress"></span>
<button type="button" class="btn btn-sm hm-live-close" x-show="!loading && liveLog.length" @click="liveLog = []">✕</button>
</div>
<ol id="hm-live-log" class="hm-live-log">
<template x-for="(entry, idx) in liveLog" :key="idx">
<li :class="'hm-live-entry hm-live-' + (entry.status || 'ok') + (entry.type === 'agent_msg' ? ' hm-live-agent' : '')">
<span class="hm-live-src" x-text="entry.source || entry.agent"></span>
<span class="hm-live-msg" x-text="entry.message"></span>
<small class="hm-live-detail" x-show="entry.detail" x-text="entry.detail"></small>
</li>
</template>
</ol>
</div>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="dashboard-customize" class="hm-customize-wrap" x-show="editLayout" x-cloak></div>
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · CEO KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's <small class="hm-widget-hint" x-show="editLayout">— kies KPI's hieronder</small></h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="executive">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Executive</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
<div id="briefing-executive-summary"></div>
</div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p>
</div>
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
<h4>📋 Herman activiteitenlog</h4>
<ul id="briefing-activity-log" class="hm-exec-list" style="margin:0;padding-left:1.1rem;font-size:0.82rem"></ul>
</div>
<div class="briefing-horizon-col">
<div class="horizon-card short">
<h4>⚡ Korte termijn</h4>
<ul id="briefing-actions-list"></ul>
</div>
<div class="horizon-card long">
<h4>🎯 Lange termijn</h4>
<ul id="briefing-longterm-list"></ul>
</div>
</div>
</div>
</section>
<section class="hm-dash-section" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col">
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub →</a></small></div>
<div id="briefing-retail"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
<div id="briefing-milestones"></div>
</div>
</div>
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
<div id="briefing-food-highlights" class="hm-highlight-scroll"></div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-dash-analytics" data-widget="analytics">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Analytics</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
</section>
<div class="hub-grid hm-dash-feed" data-widget="feed">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Feed</div>
<section class="panel hm-dash-full">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
<!-- ═══ RSS FEEDS TAB ═══ -->
<div class="herman-rss-hub" x-show="hermanTab === 'rss'" x-cloak>
<header class="herman-rss-head">
<div>
<h2>RSS Feeds</h2>
<p class="page-tip">Alle live feeds op één plek — filter op categorie of zoek op titel</p>
</div>
<div class="herman-rss-actions">
<button type="button" class="btn btn-sm btn-pulse" @click="refreshRss(true)" :disabled="rssLoading">↻ RSS ophalen</button>
<a class="btn btn-sm" href="/marketing?tab=live">Marketing Hub</a>
<a class="btn btn-sm" href="/marketing?tab=bookmarks">★ Bookmarks</a>
</div>
</header>
<div class="herman-rss-filters">
<input type="search" class="form-input" placeholder="Zoek in titels…" x-model="rssSearch" />
<select class="form-input" x-model="rssCategory" @change="loadRssTab()">
<option value="">Alle categorieën</option>
<option value="kant-en-klaar">Kant-en-klaar</option>
<option value="supermarkt">Supermarkt</option>
<option value="food">Food</option>
<option value="markt">Markt &amp; M&amp;A</option>
<option value="regelgeving">Regelgeving</option>
<option value="cbs">CBS</option>
</select>
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
</div>
<div class="herman-rss-grid">
<section class="herman-rss-col herman-rss-col-main">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
<div class="herman-rss-list">
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
<article class="feed-card herman-rss-card">
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
<div class="feed-actions">
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
</div>
</article>
</template>
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
</div>
</section>
<aside class="herman-rss-col herman-rss-col-side">
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
<h4>Regelgeving</h4>
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
<h4>CBS &amp; data</h4>
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="rssBookmarks.length">
<h4>★ Opgeslagen</h4>
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
<div class="herman-rss-side-item">
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
<small x-text="b.feed_name"></small>
</div>
</template>
</section>
</aside>
</div>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=13"></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 || '',
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
this._prefs = prefs;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
const dash = document.getElementById('briefing-dashboard');
const order = DashboardLayout.visibleLayout(prefs);
DashboardLayout.applyOrder(dash, order);
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
if (this._setDragMode) this._setDragMode(this.editLayout);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
},
scheduleSave(patch) {
if (!this._prefs) return;
if (patch.widgets) this._prefs.dashboard_widgets = patch.widgets;
if (patch.kpis) this._prefs.dashboard_kpis = patch.kpis;
if (patch.layout) this._prefs.dashboard_layout = patch.layout;
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(async () => {
await DashboardLayout.saveDashboardConfig(
this._prefs.dashboard_layout,
this._prefs.dashboard_widgets,
this._prefs.dashboard_kpis
);
this.applyDashboardPrefs(this._prefs);
}, 400);
},
toggleEditLayout() {
this.editLayout = !this.editLayout;
if (this._setDragMode) this._setDragMode(this.editLayout);
},
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.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
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;
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.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;
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 %}
+216
View File
@@ -0,0 +1,216 @@
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 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:
"""Always use live DB stats; briefing text may be cached."""
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "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_dashboard(request: Request):
kpis = {
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"browser_sessions_24h": 0,
}
try:
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
except Exception:
pass
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at
FROM browser_sessions ORDER BY created_at DESC LIMIT 8
"""
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
return templates.TemplateResponse(
"cto-dashboard.html",
{
"request": request,
"page_title": "CTO Dashboard",
"kpis": kpis,
"agent_feed": agent_feed,
"browser_sessions": browser_sessions,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+66
View File
@@ -0,0 +1,66 @@
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
(function () {
var DEFAULT = 'overview';
function radioId(tab) {
return 'doctab-' + tab;
}
function getActiveTab() {
var checked = document.querySelector('.doc-tab-input:checked');
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
}
function show(tab) {
if (!tab) tab = DEFAULT;
var radio = document.getElementById(radioId(tab));
if (radio) radio.checked = true;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
window.__docActiveTab = tab;
}
function syncAlpine(tab) {
var hub = document.querySelector('.doc-hub');
if (!hub || !window.Alpine) return;
try {
var d = Alpine.$data(hub);
if (!d) return;
if (d.activeTab !== tab) d.activeTab = tab;
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
} catch (e) {
console.warn('doc-tabs alpine', e);
}
}
function onTabChange() {
var tab = getActiveTab();
window.__docActiveTab = tab;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
syncAlpine(tab);
}
function bind() {
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
if (radio.dataset.bound) return;
radio.dataset.bound = '1';
radio.addEventListener('change', onTabChange);
});
}
window.DocTabs = { show: show, getActive: getActiveTab };
bind();
document.addEventListener('DOMContentLoaded', function () {
bind();
show(window.__docActiveTab || getActiveTab());
});
document.addEventListener('alpine:initialized', function () {
bind();
show(window.__docActiveTab || getActiveTab());
syncAlpine(getActiveTab());
});
})();
+893
View File
@@ -0,0 +1,893 @@
/* Documents & NAS Workbench v2 */
[x-cloak] { display: none !important; }
.doc-hub {
--doc-accent: #00e5ff;
--doc-gold: #ffd700;
--doc-purple: #a855f7;
--doc-border: rgba(0, 229, 255, 0.14);
--doc-text: #f1f5f9;
--doc-muted: #94a3b8;
--doc-dim: #64748b;
}
.doc-hub .page-header h1 {
font-size: 1.65rem;
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Tabs */
.doc-nav {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.85);
border: 1px solid var(--doc-border);
border-radius: 14px;
}
.doc-nav-btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--doc-muted);
transition: all 0.18s;
user-select: none;
}
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
.doc-nav-btn.active,
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"],
#doctab-analytics360:checked ~ #doc-nav label[for="doctab-analytics360"] {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
}
/* Workbench shell */
.doc-workbench {
border-radius: 16px;
border: 1px solid var(--doc-border);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
}
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
.doc-tab-input {
position: fixed;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
#doc-tab-stage {
min-height: 50vh;
}
#doc-tab-stage [data-tab-panel] {
display: none !important;
}
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
#doctab-analytics360:checked ~ #doc-tab-stage [data-tab-panel="analytics360"],
#doc-tab-stage [data-tab-panel].tab-active {
display: block !important;
min-height: 50vh;
}
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
font-size: 0.65rem;
color: var(--doc-dim);
white-space: nowrap;
}
.dl-version-bar {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
}
.doc-row-fallback { opacity: 0.85; }
.share-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
margin-bottom: 0.5rem;
font-size: 0.68rem;
}
.share-crumb {
background: transparent;
border: none;
color: #7dd3fc;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 4px;
}
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
.share-crumb-sep { color: #475569; }
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
.share-browser-list { max-height: 52vh; overflow-y: auto; }
.doc-wb-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
}
.doc-wb-head h2 {
margin: 0;
font-size: 1.05rem;
color: var(--doc-text);
}
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
.doc-kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
.doc-kpi {
padding: 0.9rem 1.1rem;
border-right: 1px solid rgba(148, 163, 184, 0.06);
cursor: pointer;
transition: background 0.15s;
}
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
.doc-kpi:last-child { border-right: none; }
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
.doc-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
/* Split layout */
.doc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
min-height: 280px;
}
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
.doc-split-col {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-split-col:last-child { border-right: none; }
.doc-chart-wrap { height: 140px; position: relative; }
/* Document table */
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
.doc-table-wrap .data-table { font-size: 0.78rem; }
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
/* Label chips */
.doc-chip {
display: inline-block;
padding: 0.18rem 0.55rem;
margin: 0.12rem 0.12rem 0 0;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
background: rgba(0, 229, 255, 0.12);
color: #7dd3fc;
border: 1px solid rgba(0, 229, 255, 0.25);
white-space: nowrap;
}
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
/* Photo grid */
.photo-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.photo-tile {
border: 1px solid var(--doc-border);
border-radius: 14px;
overflow: hidden;
background: rgba(10, 15, 26, 0.95);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.photo-tile:hover {
transform: translateY(-2px);
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
}
.photo-tile img {
width: 100%;
height: 150px;
object-fit: cover;
background: #060a10;
display: block;
}
.photo-tile-body {
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.photo-tile-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
}
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
.photo-tile-labels {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-height: 1.5rem;
align-items: flex-start;
}
.photo-tile-ocr {
font-size: 0.68rem;
line-height: 1.4;
color: var(--doc-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.8em;
}
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
/* Label filter chips row */
.label-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0 1rem 0.75rem;
}
.label-filter-chip {
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.35);
background: transparent;
color: var(--doc-muted);
}
.label-filter-chip.active, .label-filter-chip:hover {
border-color: rgba(0, 229, 255, 0.4);
color: #e0f2fe;
background: rgba(0, 229, 255, 0.1);
}
/* ── Label modal (fixed overlap + dark text) ── */
.label-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.label-modal-backdrop.open {
display: flex;
}
.label-modal {
width: min(780px, 100%);
max-height: 92vh;
overflow-y: auto;
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 20px;
padding: 1.35rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
color: var(--doc-text);
}
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
.label-modal-preview {
display: grid;
grid-template-columns: 140px 1fr;
gap: 1rem;
margin: 1rem 0;
padding: 0.85rem;
background: rgba(0, 0, 0, 0.35);
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
}
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
.label-modal-preview img {
width: 100%;
border-radius: 10px;
object-fit: cover;
max-height: 160px;
border: 1px solid rgba(148, 163, 184, 0.15);
}
.modal-ocr-box {
font-size: 0.74rem;
line-height: 1.55;
color: #cbd5e1 !important;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 8px;
padding: 0.65rem 0.75rem;
max-height: 140px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.modal-ocr-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
margin-bottom: 0.35rem;
}
.label-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 0.5rem;
}
.label-pick {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
min-height: 92px;
border-radius: 12px;
border: 1px solid rgba(100, 116, 139, 0.35);
background: rgba(15, 23, 42, 0.7);
cursor: pointer;
transition: all 0.15s;
color: #e2e8f0;
}
.label-pick:hover {
border-color: rgba(0, 229, 255, 0.4);
background: rgba(0, 229, 255, 0.06);
}
.label-pick.selected {
border-color: var(--pick-color, #00e5ff);
background: rgba(0, 229, 255, 0.14);
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
}
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
.label-pick-name {
font-size: 0.68rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
color: #cbd5e1;
display: block;
width: 100%;
word-break: break-word;
}
.label-pick.selected .label-pick-name { color: #f8fafc; }
.label-modal-extra {
margin-top: 1rem;
display: grid;
gap: 0.5rem;
}
.label-modal-extra label {
font-size: 0.72rem;
color: var(--doc-muted);
display: block;
margin-bottom: 0.25rem;
}
.label-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
/* Document label drawer */
.doc-label-drawer {
margin: 1rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid rgba(0, 229, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
}
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
/* ── Docling workbench ── */
.dl-workbench { min-height: 620px; }
.dl-body {
display: grid;
grid-template-columns: 260px 300px 1fr;
min-height: 560px;
}
/* v3 split workbench: files | editor+preview */
.dl-body-v3 {
display: grid;
grid-template-columns: minmax(260px, 300px) 1fr;
min-height: 580px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
.dl-pipeline-panel {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.18);
}
.dl-pipeline-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
.dl-workspace {
display: flex;
flex-direction: column;
min-height: 580px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.dl-workspace.empty { justify-content: center; align-items: center; }
.dl-workspace-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.dl-ws-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
flex-wrap: wrap;
}
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
.dl-workspace-panes {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
gap: 0;
}
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
.dl-pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.06);
}
.dl-pane:last-child { border-right: none; }
.dl-pane-label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0.85rem;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
gap: 0.5rem;
flex-wrap: wrap;
}
.dl-export-tabs-inline { margin: 0; }
.dl-editor-full {
flex: 1;
min-height: 420px;
max-height: none;
border: none;
border-radius: 0;
resize: none;
}
.dl-preview-viewport {
flex: 1;
min-height: 420px;
overflow: auto;
padding: 0.65rem;
}
.dl-preview-viewport .dl-preview-box {
min-height: 200px;
max-height: none;
height: 100%;
}
.dl-preview-img {
max-width: 100%;
max-height: 480px;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.dl-preview-iframe {
width: 100%;
height: 480px;
border: none;
border-radius: 8px;
background: #fff;
}
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
.dl-file-meta { flex: 1; min-width: 0; }
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
.dl-body-v3 .dl-sidebar {
max-height: none;
height: 100%;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 1200px) {
.dl-body-v3 { grid-template-columns: 1fr; }
.dl-body-v3 .dl-sidebar { max-height: 280px; }
}
@media (max-width: 1200px) {
.dl-body { grid-template-columns: 240px 1fr; }
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
}
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
overflow-y: auto;
max-height: 680px;
}
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
.dl-options { background: rgba(0, 0, 0, 0.12); }
.dl-section-title {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--doc-dim);
margin: 0 0 0.5rem;
}
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-ext-chip {
padding: 0.22rem 0.5rem;
border-radius: 6px;
font-size: 0.65rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
.dl-file-item {
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.12s;
}
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
.dl-file-item.selected {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.1);
}
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-badge {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.58rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
.dl-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(100, 116, 139, 0.2);
font-size: 0.76rem;
color: var(--doc-muted);
cursor: pointer;
transition: all 0.12s;
}
.dl-toggle:has(input:checked) {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
color: #e0f2fe;
}
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
.dl-preview-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-tab {
padding: 0.32rem 0.7rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-preview-box {
min-height: 320px;
max-height: 480px;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.1);
border-radius: 10px;
padding: 0.85rem;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
}
.dl-preview-box.rendered { white-space: normal; }
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
.dl-stat {
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(148, 163, 184, 0.1);
}
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
.dl-history-item {
padding: 0.45rem 0.55rem;
border-radius: 8px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--doc-muted);
}
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
.dl-progress {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
overflow: hidden;
margin-top: 0.5rem;
}
.dl-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00e5ff, #a855f7);
animation: dl-pulse 1.2s ease-in-out infinite;
width: 40%;
}
.dl-editor {
width: 100%;
min-height: 360px;
max-height: 520px;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.76rem;
line-height: 1.55;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.2);
border-radius: 10px;
padding: 0.85rem;
color: #e2e8f0;
resize: vertical;
}
.ppt-workbench { min-height: 560px; }
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
.ppt-editor { padding: 1rem; }
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
/* ── Document chat (RAG) ── */
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
.doc-chat-input .form-input { flex: 1; }
/* ── 360 Analytics ── */
.analytics360-workbench { min-height: 560px; }
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
.a360-links { max-height: 200px; overflow-y: auto; }
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); }
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(0,229,255,0.1); color: #7dd3fc; }
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
@keyframes dl-pulse {
0%, 100% { transform: translateX(-100%); width: 30%; }
50% { transform: translateX(200%); width: 50%; }
}
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
{% extends "base.html" %}
{% block title %}Documents · NAS Intelligence · Foodlinkk{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/documents-v2.css?v=19" />
<script>
window.__DOC_DATA = {
topWords: {{ top_words | tojson }},
summary: {{ summary | tojson }},
documents: {{ documents | tojson }},
};
</script>
<script src="/static/js/documents-v2.js?v=19"></script>
{% endblock %}
{% block content %}
<div class="doc-hub" x-data="documentsDash()" x-init="init()">
<input type="radio" name="doctab" id="doctab-overview" class="doc-tab-input" value="overview" checked aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-labels" class="doc-tab-input" value="labels" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-docling" class="doc-tab-input" value="docling" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-powerpoint" class="doc-tab-input" value="powerpoint" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-chat" class="doc-tab-input" value="chat" aria-hidden="true" tabindex="-1" />
<input type="radio" name="doctab" id="doctab-analytics360" class="doc-tab-input" value="analytics360" aria-hidden="true" tabindex="-1" />
<header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>NAS Document Intelligence</h1>
<p class="hero-sub">Corpus · sentiment · labeling · Docling conversie</p>
<p class="page-tip" id="doc-hub-status" style="margin-top:0.5rem;color:#fbbf24;display:none"></p>
</header>
<nav class="doc-nav" id="doc-nav" aria-label="Document secties">
<label for="doctab-overview" class="doc-nav-btn">Overzicht</label>
<label for="doctab-labels" class="doc-nav-btn">Labels &amp; foto's</label>
<label for="doctab-docling" class="doc-nav-btn">Workbench</label>
<label for="doctab-powerpoint" class="doc-nav-btn">PowerPoint</label>
<label for="doctab-chat" class="doc-nav-btn">Chat</label>
<label for="doctab-analytics360" class="doc-nav-btn">360° Klant</label>
</nav>
<div id="doc-tab-stage" class="doc-tab-stage">
<!-- ═══ OVERVIEW ═══ -->
<div data-tab-panel="overview" class="doc-workbench doc-panel">
<div class="doc-kpi-strip">
<div class="doc-kpi" @click="filterSentiment=''; applyFilters()">
<div class="lbl">Documenten</div>
<div class="val" x-text="filteredDocs.length"></div>
</div>
<div class="doc-kpi">
<div class="lbl">Totaal woorden</div>
<div class="val">{{ summary.total_words }}</div>
</div>
<div class="doc-kpi" @click="switchTab('labels')">
<div class="lbl">Foto's OCR</div>
<div class="val" x-text="photos.length"></div>
</div>
<div class="doc-kpi">
<div class="lbl">Gem. sentiment</div>
<div class="val">{{ summary.avg_sentiment }}</div>
</div>
</div>
<div class="doc-toolbar">
<input type="search" class="form-input" placeholder="Zoek bestand of label…" x-model="filterQuery" @input="applyFilters()" />
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
<option value="">Alle sentiment</option>
<option value="positive">Positief</option>
<option value="neutral">Neutraal</option>
<option value="negative">Negatief</option>
</select>
<select class="form-input" x-model="filterType" @change="applyFilters()">
<option value="">Alle types</option>
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
</select>
<select class="form-input" x-model="filterLabel" @change="applyFilters()">
<option value="">Alle labels</option>
<template x-for="lb in labelVocabulary" :key="lb.label">
<option :value="lb.label" x-text="lb.label + ' (' + lb.usage_count + ')'"></option>
</template>
</select>
<label style="font-size:0.78rem;color:#64748b;display:flex;align-items:center;gap:0.35rem">
<input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live
</label>
<button class="btn btn-secondary btn-sm" @click="openSelectedInWorkbench()" :disabled="!selectedSharePaths.length">Open in Workbench (<span x-text="selectedSharePaths.length"></span>)</button>
<button class="btn btn-secondary btn-sm" @click="refreshShare()">Share scan</button>
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">
<span x-text="nasOcrBusy ? 'OCR…' : 'NAS OCR'"></span>
</button>
</div>
<div class="doc-split">
<div class="doc-split-col">
<div class="doc-section-title" style="font-size:0.65rem;text-transform:uppercase;letter-spacing:0.1em;color:#64748b;margin-bottom:0.5rem">Analytics</div>
<div class="doc-chart-wrap"><canvas id="wordsChart"></canvas></div>
<div class="doc-chart-wrap" style="margin-top:0.75rem"><canvas id="sentimentChart"></canvas></div>
</div>
<div class="doc-split-col doc-table-wrap">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<strong style="color:#f1f5f9;font-size:0.85rem">NAS documenten</strong>
<span style="font-size:0.72rem;color:#64748b" x-text="filteredDocs.length + ' resultaten'"></span>
</div>
<table class="data-table">
<thead>
<tr><th style="width:2rem"></th><th>Bestand</th><th>Gewijzigd</th><th>Labels</th><th>Sentiment</th><th>Woorden</th><th></th></tr>
</thead>
<tbody id="doc-table-body">
{% for doc in documents[:80] %}
<tr class="clickable-row doc-row-fallback">
<td></td>
<td><strong>{{ doc.filename }}</strong><span class="path-cell">{{ doc.storage_path }}</span></td>
<td class="doc-ts-cell">{{ doc.modified_at or doc.analyzed_at or '—' }}</td>
<td>{% if doc.user_labels %}{% for lb in doc.user_labels %}<span class="doc-chip">{{ lb }}</span>{% endfor %}{% else %}<span class="doc-chip muted">—</span>{% endif %}</td>
<td><span class="badge badge-{{ doc.sentiment_label or 'neutral' }}">{{ doc.sentiment_label or '—' }}</span></td>
<td>{{ doc.word_count or 0 }}</td>
<td></td>
</tr>
{% endfor %}
<template x-for="doc in filteredDocs" :key="doc.storage_path">
<tr class="clickable-row" :class="activeDoc && activeDoc.storage_path === doc.storage_path && 'doc-row-selected'">
<td @click.stop>
<input type="checkbox" :checked="isShareSelected(doc.storage_path)" @change="toggleShareSelect(doc.storage_path)" />
</td>
<td @click="openDocLabel(doc)">
<strong x-text="doc.filename"></strong>
<span class="path-cell" x-text="doc.storage_path"></span>
</td>
<td class="doc-ts-cell" x-text="formatTs(doc.modified_at || doc.analyzed_at)"></td>
<td @click="openDocLabel(doc)">
<template x-for="lb in (doc.user_labels || [])" :key="lb">
<span class="doc-chip" x-text="lb"></span>
</template>
<span class="doc-chip muted" x-show="!(doc.user_labels || []).length">—</span>
</td>
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
<td x-text="doc.word_count"></td>
<td @click.stop>
<button type="button" class="btn btn-sm btn-secondary" @click="openInWorkbench(doc.storage_path)">Bewerk</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div class="doc-label-drawer" x-show="activeDoc">
<h3>Label document — <span x-text="activeDoc && activeDoc.filename"></span></h3>
<div class="label-modal-grid">
<template x-for="t in documentTaxonomy" :key="t.id">
<button type="button" class="label-pick" :class="docSelectedLabels.includes(t.id) && 'selected'"
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleDocLabel(t.id)">
<span class="label-pick-emoji" x-text="t.emoji"></span>
<span class="label-pick-name" x-text="t.label"></span>
</button>
</template>
</div>
<div class="label-modal-extra">
<div><label>Extra labels</label><input type="text" class="form-input" x-model="docLabelsInput" placeholder="komma-gescheiden" /></div>
<div><label>Notities</label><textarea class="form-input" rows="2" x-model="docNotesInput"></textarea></div>
</div>
<div class="label-modal-actions" style="border:none;padding-top:0.75rem">
<button class="btn btn-secondary btn-sm" @click="activeDoc=null">Sluiten</button>
<button class="btn btn-primary btn-sm" @click="saveDocLabels()">Opslaan</button>
<button class="btn btn-secondary btn-sm" @click="doclingSelected=activeDoc.storage_path; switchTab('docling')">→ Docling</button>
</div>
</div>
</div>
<!-- ═══ LABELS & PHOTOS ═══ -->
<div data-tab-panel="labels" class="doc-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Foto labeling studio</h2>
<p>Klik een foto → kies label-type → opslaan</p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:200px" />
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">Vernieuwen</button>
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">NAS OCR</button>
</div>
</div>
<div class="label-filter-row">
<button type="button" class="label-filter-chip" :class="!photoFilterLabel && 'active'" @click="photoFilterLabel=''">Alle</button>
<template x-for="t in photoTaxonomy" :key="'f'+t.id">
<button type="button" class="label-filter-chip" :class="photoFilterLabel===t.id && 'active'"
@click="photoFilterLabel=t.id" x-text="t.emoji + ' ' + t.label"></button>
</template>
</div>
<div class="photo-masonry">
<template x-for="p in filteredPhotos()" :key="p.id">
<article class="photo-tile" @click="openPhotoModal(p.id)">
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
<div class="photo-tile-body">
<div class="photo-tile-head">
<span class="photo-tile-id" x-text="'#' + p.id"></span>
<span class="photo-tile-src" x-text="p.source || 'upload'"></span>
</div>
<div class="photo-tile-labels">
<template x-for="lb in (p.user_labels || [])" :key="lb">
<span class="doc-chip" x-text="labelDisplayName(lb)"></span>
</template>
<span class="doc-chip muted" x-show="!(p.user_labels || []).length">Nog geen label</span>
</div>
<p class="photo-tile-ocr" x-text="p.ocr_preview || 'Geen OCR tekst'"></p>
<span class="photo-tile-meta" x-text="(p.box_count||0) + ' detecties · ' + (p.item_count||0) + ' items'"></span>
</div>
</article>
</template>
<p class="empty-state" x-show="!filteredPhotos().length" style="grid-column:1/-1;padding:2rem">Geen foto's — upload, Telegram of NAS OCR.</p>
</div>
</div>
<!-- ═══ DOCLING STUDIO ═══ -->
<div data-tab-panel="docling" class="doc-workbench dl-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Document Workbench</h2>
<p x-text="doclingStatus"></p>
<div class="dl-progress" x-show="doclingBusy"><div class="dl-progress-bar"></div></div>
</div>
<div class="dl-head-actions">
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="doclingAutoConvert" /> Auto-convert bij klik</label>
<label class="dl-toggle"><input type="checkbox" x-model="shareBrowseRecursive" @change="browseShare(shareBrowsePath || '', false)" /> Incl. submappen</label>
<button class="btn btn-secondary btn-sm" :disabled="nasLoading || shareBrowseLoading" @click="loadAllNasFiles(true); browseShare(shareBrowsePath || '', true)"><span x-text="(nasLoading || shareBrowseLoading) ? 'Scannen…' : '↻ NAS'"></span></button>
<button class="btn btn-secondary btn-sm" @click="dlOptionsOpen=!dlOptionsOpen" x-text="dlOptionsOpen ? 'Pipeline ▲' : 'Pipeline ▼'"></button>
<button class="btn btn-primary btn-sm" :disabled="doclingBusy || !doclingSelected" @click="runDoclingConvert()">↻ Convert</button>
<button class="btn btn-secondary btn-sm" :disabled="doclingBusy" @click="runDoclingBatch()">Batch</button>
</div>
</div>
<div class="dl-pipeline-panel" x-show="dlOptionsOpen">
<div class="dl-pipeline-grid">
<div>
<p class="dl-section-title">Pipeline</p>
<div class="dl-toggle-grid">
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.ocr" /> OCR</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.tables" /> Tabellen</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.page_images" /> Pagina's</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.picture_images" /> Figuren</label>
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.force_full_page_ocr" /> Full-page OCR</label>
</div>
</div>
<div>
<p class="dl-section-title">Export</p>
<div class="dl-format-grid">
<template x-for="fmt in doclingExportFormats" :key="fmt">
<label class="dl-toggle" style="padding:0.35rem 0.5rem">
<input type="checkbox" :value="fmt" x-model="doclingFormats" />
<span x-text="fmt"></span>
</label>
</template>
</div>
</div>
<div class="dl-stats-grid" x-show="doclingResult">
<div class="dl-stat"><div class="n" x-text="doclingMeta.page_count || 0"></div><div class="l">Pagina's</div></div>
<div class="dl-stat"><div class="n" x-text="doclingTables.length"></div><div class="l">Tabellen</div></div>
<div class="dl-stat"><div class="n" x-text="doclingPictures.length"></div><div class="l">Figuren</div></div>
<div class="dl-stat"><div class="n" x-text="doclingAvailableExports().length"></div><div class="l">Exports</div></div>
</div>
</div>
</div>
<div class="dl-body-v3">
<!-- Bestanden links -->
<aside class="dl-sidebar">
<p class="dl-section-title">Share <span x-text="'(' + (shareBrowseRecursive ? 'recursief · ' : '') + shareBrowsePath + ')'"></span></p>
<nav class="share-breadcrumb" aria-label="Share pad">
<button type="button" class="share-crumb" @click="browseShare('')">Share</button>
<template x-for="(part, idx) in sharePathParts()" :key="'bc'+idx">
<span class="share-crumb-sep">/</span>
<button type="button" class="share-crumb" @click="browseShare(sharePathPrefix(idx))" x-text="part"></button>
</template>
</nav>
<div class="dl-ext-chips">
<template x-for="g in dlExtGroups" :key="g.ext || 'all'">
<button type="button" class="dl-ext-chip" :class="doclingExtFilter===g.ext && 'active'" @click="doclingExtFilter=g.ext" x-text="(g.icon ? g.icon + ' ' : '') + g.label"></button>
</template>
</div>
<input type="search" class="form-input dl-search" placeholder="Zoek in map…" x-model="doclingFilter" />
<div class="dl-file-list share-browser-list">
<div class="dl-file-item share-folder-item" x-show="shareBrowsePath" @click="browseShareParent()">
<span class="dl-file-icon">📁</span>
<span class="dl-file-name">.. (omhoog)</span>
</div>
<template x-for="entry in shareBrowseFolders()" :key="'d'+entry.path">
<div class="dl-file-item share-folder-item" @click="browseShare(entry.path)">
<span class="dl-file-icon">📁</span>
<div class="dl-file-meta">
<span class="dl-file-name" x-text="entry.name"></span>
<span class="dl-file-path" x-text="entry.path"></span>
</div>
</div>
</template>
<template x-for="f in doclingFilteredFiles()" :key="f.path">
<div class="dl-file-item" :class="doclingSelected===f.path && 'selected'" @click.prevent="selectDoclingFile(f.path)">
<div class="dl-file-row">
<span class="dl-file-icon" x-text="fileIcon(f.path)"></span>
<div class="dl-file-meta">
<span class="dl-file-name" x-text="f.filename || f.path"></span>
<span class="dl-file-path" x-text="f.path"></span>
</div>
<span class="dl-file-ext" x-text="fileExtension(f.path)"></span>
</div>
<div class="dl-file-tags">
<span class="dl-file-ts" x-text="formatTs(f.modified_at)"></span>
<span class="dl-file-badge" x-show="fileDoclingStatus(f.path)==='done'">✓</span>
<span class="dl-file-badge pending" x-show="fileDoclingStatus(f.path)==='running'">…</span>
<span class="dl-file-badge failed" x-show="fileDoclingStatus(f.path)==='failed'">✕</span>
<span class="dl-file-badge convert" x-show="doclingSelected===f.path && doclingFileLoading">laden</span>
</div>
</div>
</template>
<p class="empty-state" x-show="!shareBrowseFolders().length && !doclingFilteredFiles().length" style="font-size:0.72rem">Geen items — klik ↻ NAS of controleer Synology rechten.</p>
</div>
</aside>
<!-- Workspace rechts: editor + preview -->
<div class="dl-workspace" :class="!doclingSelected && 'empty'">
<div class="dl-workspace-inner" x-show="doclingSelected" style="display:flex;flex-direction:column;flex:1">
<div class="dl-ws-head">
<div class="dl-ws-title">
<span class="dl-ws-icon" x-text="fileIcon(doclingSelected)"></span>
<div>
<strong x-text="doclingSelected.split('/').pop()"></strong>
<span class="dl-ws-path" x-text="doclingSelected"></span>
</div>
<span class="dl-ws-kind" x-text="fileKindLabel(doclingSelected)"></span>
<span class="dl-ws-ts" x-show="doclingFileMtime" x-text="'NAS: ' + formatTs(doclingFileMtime)"></span>
</div>
<div class="dl-version-bar" x-show="docVersions.length">
<label style="font-size:0.72rem;color:#94a3b8">Versie</label>
<select class="form-input" style="max-width:280px;font-size:0.72rem" @change="loadVersion($event.target.value)">
<option value="">Huidige bewerking</option>
<template x-for="v in docVersions" :key="v.id">
<option :value="v.id" x-text="formatTs(v.created_at) + (v.version_label ? ' · ' + v.version_label : '')"></option>
</template>
</select>
</div>
<div class="dl-preview-tools">
<button class="btn btn-sm btn-primary" :disabled="!doclingEditContent" @click="saveWorkspace()">Opslaan</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingEditContent" @click="saveToNas()">→ NAS</button>
<button class="btn btn-sm btn-secondary" @click="sendToPowerpoint()" x-show="doclingEditContent">→ PPT</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="copyDoclingExport()">Kopieer</button>
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="downloadDoclingExport()">Download</button>
<a class="btn btn-sm btn-secondary" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" target="_blank" rel="noopener">Open origineel</a>
</div>
</div>
<div class="dl-loading-bar" x-show="doclingFileLoading || doclingBusy"><div class="dl-progress-bar"></div></div>
<div class="dl-workspace-panes">
<!-- Editor -->
<div class="dl-pane dl-pane-editor">
<div class="dl-pane-label">Editor <span x-text="doclingFileKind === 'text' ? '(raw)' : '(markdown)'"></span></div>
<textarea class="dl-editor dl-editor-full" x-model="doclingEditContent" :placeholder="doclingFileLoading ? 'Laden…' : 'Klik een bestand links — inhoud verschijnt hier…'" :disabled="doclingFileLoading"></textarea>
</div>
<!-- Preview -->
<div class="dl-pane dl-pane-preview">
<div class="dl-pane-label">
Preview
<div class="dl-export-tabs dl-export-tabs-inline" x-show="doclingResult">
<template x-for="fmt in doclingAvailableExports()" :key="'t'+fmt">
<button type="button" class="dl-tab" :class="doclingExportTab===fmt && 'active'" @click="doclingExportTab=fmt" x-text="fmt"></button>
</template>
<button type="button" class="dl-tab" :class="doclingExportTab==='rendered' && 'active'" @click="doclingExportTab='rendered'" x-show="doclingExports.markdown">md</button>
<button type="button" class="dl-tab" :class="doclingExportTab==='original' && 'active'" @click="doclingExportTab='original'">orig</button>
</div>
</div>
<div class="dl-preview-viewport">
<img x-show="doclingFileKind==='image'" class="dl-preview-img" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" :alt="doclingSelected" />
<iframe x-show="doclingFileKind==='pdf' && doclingExportTab==='original'" class="dl-preview-iframe" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'"></iframe>
<div x-show="doclingExportTab==='html' && doclingPreviewHtml()" class="dl-preview-box rendered" x-html="doclingPreviewHtml()"></div>
<div x-show="doclingExportTab==='rendered' && doclingExports.markdown" class="dl-preview-box rendered" x-html="doclingRenderedMarkdown()"></div>
<pre class="dl-preview-box" x-show="doclingResult && doclingExportTab!=='html' && doclingExportTab!=='rendered' && doclingExportTab!=='original' && doclingExportTab!=='tables' && doclingExportTab!=='pictures'" x-text="doclingPreviewText()"></pre>
<p class="empty-state" x-show="!doclingResult && !doclingFileLoading && doclingFileKind!=='image' && doclingFileKind!=='pdf'" style="padding:2rem">Converteert… of geen preview beschikbaar.</p>
</div>
</div>
</div>
</div>
</div>
<div class="dl-workspace-empty" x-show="!doclingSelected">
<p class="empty-state" style="padding:3rem 1rem;text-align:center">
<span style="font-size:2rem;display:block;margin-bottom:0.5rem">📂</span>
Klik een bestand links om direct te bewerken.<br/>
<span style="font-size:0.78rem;color:#64748b">PDF · Word · Excel · PowerPoint · tekst · afbeeldingen — auto-convert aan</span>
</p>
</div>
</div>
</div>
</div>
<!-- ═══ POWERPOINT STUDIO ═══ -->
<div data-tab-panel="powerpoint" class="doc-workbench ppt-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>PowerPoint Studio</h2>
<p>Maak presentaties van NAS PDF's of bewerkte Docling-markdown</p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" @click="loadAllNasFiles(false)">↻ Bestanden</button>
<button class="btn btn-primary btn-sm" :disabled="pptBusy || !pptSlides.length" @click="createPptx()">Genereer PPTX</button>
</div>
</div>
<div class="ppt-grid">
<aside class="ppt-sidebar">
<p class="dl-section-title">Bron PDF (NAS)</p>
<div class="dl-file-list">
<template x-for="f in pptPdfFiles()" :key="'pdf'+f.path">
<div class="dl-file-item" :class="pptSourcePdf===f.path && 'selected'" @click="pptSourcePdf=f.path">
<span class="dl-file-name" x-text="f.filename"></span>
<span class="dl-file-path" x-text="f.path"></span>
</div>
</template>
</div>
<button class="btn btn-sm btn-secondary" style="width:100%;margin-top:0.5rem" :disabled="!pptSourcePdf || pptBusy" @click="convertPdfToPptx()">PDF → PPTX (smart)</button>
<p class="dl-section-title" style="margin-top:1rem">Bestaande PPTX</p>
<div class="dl-file-list">
<template x-for="f in pptPptxFiles()" :key="'ppt'+f.path">
<div class="dl-file-item" :class="pptDownloadPath===f.path && 'selected'" @click.prevent="openInWorkbench(f.path)">
<span class="dl-file-name" x-text="f.filename"></span>
<span class="dl-file-path" x-text="f.path"></span>
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.25rem" @click.stop="openInWorkbench(f.path)">Open in Workbench</button>
</div>
</template>
</div>
</aside>
<div class="ppt-editor">
<div class="ppt-meta-row">
<input class="form-input" placeholder="Presentatie titel" x-model="pptTitle" />
<input class="form-input" placeholder="Subtitel" x-model="pptSubtitle" />
<input class="form-input" placeholder="Bestandsnaam (optioneel)" x-model="pptFilename" />
</div>
<div style="display:flex;gap:0.45rem;margin-bottom:0.75rem;flex-wrap:wrap">
<button class="btn btn-sm btn-secondary" @click="importMarkdownToSlides()">Importeer Docling markdown</button>
<button class="btn btn-sm btn-secondary" @click="addPptSlide()">+ Slide</button>
</div>
<div class="ppt-slides">
<template x-for="(slide, idx) in pptSlides" :key="'slide'+idx">
<div class="ppt-slide-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem">
<strong style="font-size:0.78rem;color:#94a3b8">Slide <span x-text="idx+1"></span></strong>
<button class="btn btn-sm btn-secondary" @click="removePptSlide(idx)">✕</button>
</div>
<input class="form-input" placeholder="Slide titel" x-model="slide.title" style="margin-bottom:0.35rem" />
<textarea class="form-input" rows="4" placeholder="Bullets (één per regel)" x-model="slide.bulletsText" @input="syncSlideBullets(slide)"></textarea>
</div>
</template>
<p class="empty-state" x-show="!pptSlides.length">Voeg slides toe of importeer uit Docling.</p>
</div>
<p class="page-tip" x-show="pptLastOutput" style="margin-top:0.75rem">Laatste export: <span x-text="pptLastOutput"></span></p>
</div>
</div>
</div>
<!-- ═══ DOCUMENT CHAT (RAG) ═══ -->
<div data-tab-panel="chat" class="doc-workbench doc-chat-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>Chat met je data</h2>
<p>Herman + RAG over NAS-documenten · <span x-text="chatStatus"></span></p>
<p class="page-tip" x-show="nasDiagHint" x-text="nasDiagHint" style="margin-top:0.35rem;color:#fbbf24"></p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center">
<select class="form-input" style="min-width:180px;font-size:0.75rem" x-model.number="chatLlmProviderId" @change="onChatProviderChange()">
<option value="">Standaard LLM</option>
<template x-for="p in llmProviders" :key="'llm'+p.id">
<option :value="p.id" x-text="p.label + ' · ' + p.model + (p.is_default ? ' ★' : '')"></option>
</template>
</select>
<a class="btn btn-sm btn-secondary" href="/settings?tab=ai" target="_blank">+ LLM API</a>
<button class="btn btn-secondary btn-sm" @click="reindexNas()" :disabled="chatReindexing"><span x-text="chatReindexing ? 'Indexeren…' : '↻ Index NAS'"></span></button>
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatUseHerman" /> Herman orchestrator (langzamer)</label>
<button class="btn btn-secondary btn-sm" @click="runAutoSync()" :disabled="autoSyncBusy"><span x-text="autoSyncBusy ? 'Sync…' : '⟳ Second Brain'"></span></button>
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatScopeFile" /> Scope: bestand</label>
</div>
</div>
<div class="doc-chat-layout">
<div class="doc-chat-messages" id="doc-chat-log">
<template x-for="(m, i) in chatMessages" :key="'cm'+i">
<div class="doc-chat-msg" :class="m.role">
<span class="doc-chat-role" x-text="m.role === 'user' ? 'Jij' : (m.agent || 'Herman')"></span>
<p x-text="m.content"></p>
<div class="doc-chat-sources" x-show="m.sources && m.sources.length">
<span style="font-size:0.62rem;color:#64748b">Bronnen:</span>
<template x-for="s in m.sources" :key="s">
<a class="doc-chat-src" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(s)" target="_blank" x-text="s.split('/').pop()"></a>
</template>
</div>
</div>
</template>
<p class="empty-state" x-show="!chatMessages.length" style="padding:2rem;text-align:center">
Stel een vraag over je documenten.<br/>
<span style="font-size:0.78rem;color:#64748b">bv. "Wat staat er in de Cucina catalogue?" of "Samenvat halal certificaten"</span>
</p>
</div>
<aside class="doc-chat-side">
<p class="dl-section-title">Snel zoeken</p>
<input class="form-input dl-search" placeholder="Zoek in corpus…" x-model="chatSearchQ" @keydown.enter="runChatSearch()" />
<button class="btn btn-sm btn-secondary" style="width:100%;margin-bottom:0.75rem" @click="runChatSearch()">Zoek</button>
<div class="doc-chat-hits">
<template x-for="h in chatSearchHits" :key="h.id || h.path">
<div class="doc-chat-hit" @click="chatInput = 'Wat staat er in ' + (h.filename || h.path) + '?'">
<strong x-text="h.filename || h.path"></strong>
<p x-text="(h.text || '').slice(0, 120) + '…'"></p>
</div>
</template>
</div>
<p class="dl-section-title" style="margin-top:1rem">Scope</p>
<p class="page-tip" x-show="chatScopeClient && linkClientId" x-text="linkedClientName()"></p>
<p class="page-tip" x-show="chatScopeFile && doclingSelected" x-text="'Bestand: ' + doclingSelected"></p>
</aside>
</div>
<form class="doc-chat-input" @submit.prevent="sendDocChat()">
<input class="form-input" x-model="chatInput" placeholder="Vraag aan Herman over je documenten…" :disabled="chatBusy" autocomplete="off" />
<button class="btn btn-primary" type="submit" :disabled="chatBusy || !chatInput.trim()"><span x-text="chatBusy ? '…' : 'Stuur'"></span></button>
</form>
</div>
<!-- ═══ 360° KLANT ANALYTICS ═══ -->
<div data-tab-panel="analytics360" class="doc-workbench analytics360-workbench doc-panel">
<div class="doc-wb-head">
<div>
<h2>360° Klant Intelligence</h2>
<p>Koppel NAS → klant/project · trends · second brain · <span x-text="autoSyncLabel"></span></p>
</div>
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
<button class="btn btn-primary btn-sm" @click="linkSelectedFile()" :disabled="!doclingSelected || !linkClientId">+ Koppel bestand</button>
<button class="btn btn-secondary btn-sm" @click="linkSelectedFolder()" :disabled="!doclingSelected || !linkClientId">+ Koppel map</button>
</div>
</div>
<div class="a360-toolbar">
<select class="form-input" x-model.number="linkClientId" @change="loadClient360()">
<option value="">— Klant —</option>
<template x-for="c in linkClients" :key="'lc'+c.id">
<option :value="c.id" x-text="c.name"></option>
</template>
</select>
<select class="form-input" x-model.number="linkProjectId">
<option value="">— Project (optioneel) —</option>
<template x-for="p in linkProjectsForClient()" :key="'lp'+p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<input class="form-input" placeholder="NAS pad (of selecteer in Workbench)" x-model="linkPathInput" />
</div>
<div class="a360-grid" x-show="linkClientId">
<div class="a360-kpis">
<div class="dl-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
<div class="dl-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Gekoppelde paden</p>
<div class="a360-links">
<template x-for="l in client360.links || []" :key="'lnk'+l.id">
<div class="a360-link-row">
<span x-text="(l.is_folder ? '📁 ' : '📄 ') + l.storage_path"></span>
<button class="btn btn-sm btn-secondary" @click="unlinkDocument(l.id)">✕</button>
</div>
</template>
<p class="empty-state" x-show="!(client360.links||[]).length">Nog geen koppelingen — selecteer bestand in Workbench</p>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Sentiment &amp; trends</p>
<div class="a360-sentiment">
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
</template>
</div>
<div class="a360-trends">
<template x-for="t in client360.monthly_trends || []" :key="t.month">
<div class="a360-trend-row">
<span x-text="(t.month||'').slice(0,7)"></span>
<span x-text="t.docs + ' docs'"></span>
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
</div>
</template>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Top woorden (klant corpus)</p>
<div class="a360-words">
<template x-for="w in client360.top_words || []" :key="w.lemma">
<span class="a360-word" x-text="w.lemma + ' (' + w.total + ')'"></span>
</template>
</div>
</div>
<div class="a360-panel">
<p class="dl-section-title">Retail &amp; deals</p>
<template x-for="s in client360.stores || []" :key="'st'+s.id">
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
</template>
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
</template>
</div>
</div>
<p class="empty-state" x-show="!linkClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
</div>
</div><!-- /doc-tab-stage -->
<!-- ═══ PHOTO LABEL MODAL ═══ -->
<div class="label-modal-backdrop" :class="photoModalOpen && 'open'"
@click.self="closePhotoModal()" @keydown.escape.window="closePhotoModal()">
<div class="label-modal" @click.stop role="dialog" aria-labelledby="labelModalTitle">
<div class="label-modal-head" style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 id="labelModalTitle">Wat voor label is deze foto?</h3>
<p class="modal-sub">Foto #<span x-text="activePhoto"></span> — selecteer één of meer types</p>
</div>
<button type="button" class="btn btn-sm btn-secondary" @click="closePhotoModal()" aria-label="Sluiten">✕</button>
</div>
<div class="label-modal-preview" x-show="activePhoto">
<img :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" alt="Preview" />
<div>
<div class="modal-ocr-label">OCR tekst</div>
<pre class="modal-ocr-box" x-text="activeOcr || 'Geen tekst gedetecteerd'"></pre>
</div>
</div>
<div class="label-modal-grid">
<template x-for="t in photoTaxonomy" :key="t.id">
<button type="button" class="label-pick" :class="modalPhotoLabels.includes(t.id) && 'selected'"
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleModalLabel(t.id)">
<span class="label-pick-emoji" x-text="t.emoji"></span>
<span class="label-pick-name" x-text="t.label"></span>
</button>
</template>
</div>
<div class="label-modal-extra">
<div>
<label>Extra label (optioneel)</label>
<input type="text" class="form-input" x-model="photoLabelsInput" placeholder="bv. Q2-2026, Jumbo" />
</div>
<div>
<label>Notities</label>
<textarea class="form-input" rows="2" x-model="photoNotesInput" placeholder="Toelichting…"></textarea>
</div>
</div>
<div class="label-modal-actions">
<button type="button" class="btn btn-secondary" @click="closePhotoModal()">Annuleren</button>
<button type="button" class="btn btn-primary" @click="saveModalPhotoLabels()">Label opslaan</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/doc-tabs.js?v=19"></script>
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overview",
"cto_home": "CTO Overview",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Clients",
"deals": "Deals",
"products": "Products",
"suppliers": "Suppliers",
"intel": "Intel",
"beurs": "Market Live",
"retail": "Retail",
"browser": "Browser",
"documents": "Documents",
"projects": "Projects",
"hermes": "Hermes",
"telegram": "Telegram",
"agents": "Agents",
"marketing": "Marketing",
"chat": "Chat",
"studio": "Studio",
"packaging": "Packaging",
"reports": "Reports",
"system": "System",
"analytics": "Analytics",
"voice": "Voice",
"ops": "IT Ops",
"settings": "Settings",
"gitea": "Gitea"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"refresh": "Refresh",
"loading": "Loading…",
"none": "—",
"yes": "Yes",
"no": "No",
"confirm_delete": "Are you sure you want to delete this?",
"saved": "Saved",
"deleted": "Deleted",
"error": "Error",
"all": "All",
"actions": "Actions",
"language": "Language",
"dutch": "Nederlands",
"english": "English"
},
"clients": {
"title": "CRM · Clients",
"subtitle": "Manage clients · pipeline · link stores · enrich branch data",
"new": "New client",
"tab_kanban": "Kanban",
"tab_list": "List",
"tab_stores": "Supermarkets",
"tab_360": "360° Client"
},
"products": {
"title": "Product catalog",
"subtitle": "SKUs · margins · client links · launch planning",
"new": "New product",
"tab_grid": "Overview",
"tab_list": "List",
"tab_margin": "Margin calculator",
"total": "Total products",
"active": "Active",
"avg_margin": "Avg. margin",
"with_client": "With client",
"name": "Product name",
"status": "Status",
"margin_pct": "Margin %",
"moq": "MOQ",
"shelf_target": "Shelf / listing",
"launch_date": "Launch date",
"client": "Client",
"status_concept": "Concept",
"status_development": "Development",
"status_active": "Active",
"status_discontinued": "Discontinued",
"calc_cost": "Cost price",
"calc_sell": "Sell price",
"calc_profit": "Profit",
"calc_margin": "Margin",
"calc_run": "Calculate",
"no_products": "No products yet — add your first SKU.",
"drawer_deals": "Linked client"
},
"suppliers": {
"title": "Suppliers",
"subtitle": "Vendor registry · ratings · lead times · contacts",
"new": "New supplier",
"tab_grid": "Overview",
"tab_list": "List",
"total": "Total suppliers",
"avg_rating": "Avg. rating",
"countries": "Countries",
"avg_lead": "Avg. lead time",
"name": "Company name",
"country": "Country",
"category": "Category",
"rating": "Rating",
"moq": "MOQ",
"lead_time": "Lead time (days)",
"contact": "Contact",
"cat_meat": "Meat",
"cat_dairy": "Dairy",
"cat_spices": "Spices",
"cat_packaging": "Packaging",
"cat_logistics": "Logistics",
"cat_ingredients": "Ingredients",
"cat_other": "Other",
"no_suppliers": "No suppliers registered yet.",
"filter_country": "Filter country",
"filter_category": "Filter category"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Cucina · Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0e14",
"theme_color": "#0a0e14",
"lang": "nl",
"icons": [
{
"src": "/static/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+128
View File
@@ -0,0 +1,128 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overzicht",
"cto_home": "CTO Overzicht",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Klanten",
"deals": "Deals",
"products": "Producten",
"suppliers": "Leveranciers",
"intel": "Intel",
"beurs": "Beurs Live",
"retail": "Retail",
"browser": "Browser",
"documents": "Documenten",
"projects": "Projecten",
"hermes": "Hermes",
"telegram": "Telegram",
"agents": "Agents",
"marketing": "Marketing",
"chat": "Chat",
"studio": "Studio",
"packaging": "Packaging",
"reports": "Rapporten",
"system": "Systeem",
"analytics": "Analytics",
"voice": "Voice",
"ops": "IT Ops",
"settings": "Instellingen",
"gitea": "Gitea"
},
"common": {
"save": "Opslaan",
"cancel": "Annuleren",
"delete": "Verwijderen",
"edit": "Bewerken",
"add": "Toevoegen",
"search": "Zoeken",
"refresh": "Verversen",
"loading": "Laden…",
"none": "—",
"yes": "Ja",
"no": "Nee",
"confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
"saved": "Opgeslagen",
"deleted": "Verwijderd",
"error": "Fout",
"all": "Alle",
"actions": "Acties",
"language": "Taal",
"dutch": "Nederlands",
"english": "English"
},
"clients": {
"title": "CRM · Klanten",
"subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
"new": "Nieuwe klant",
"tab_kanban": "Kanban",
"tab_list": "Lijst",
"tab_stores": "Supermarkten",
"tab_360": "360° Klant"
},
"products": {
"title": "Productcatalogus",
"subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
"new": "Nieuw product",
"tab_grid": "Overzicht",
"tab_list": "Lijst",
"tab_margin": "Marge calculator",
"total": "Totaal producten",
"active": "Actief",
"avg_margin": "Gem. marge",
"with_client": "Met klant",
"name": "Productnaam",
"status": "Status",
"margin_pct": "Marge %",
"moq": "MOQ",
"shelf_target": "Schap / listing",
"launch_date": "Launch datum",
"client": "Klant",
"status_concept": "Concept",
"status_development": "Ontwikkeling",
"status_active": "Actief",
"status_discontinued": "Stopgezet",
"calc_cost": "Inkoopprijs",
"calc_sell": "Verkoopprijs",
"calc_profit": "Winst",
"calc_margin": "Marge",
"calc_run": "Berekenen",
"no_products": "Nog geen producten — voeg je eerste SKU toe.",
"drawer_deals": "Gekoppelde klant"
},
"suppliers": {
"title": "Leveranciers",
"subtitle": "Vendor registry · ratings · levertijden · contacten",
"new": "Nieuwe leverancier",
"tab_grid": "Overzicht",
"tab_list": "Lijst",
"total": "Totaal leveranciers",
"avg_rating": "Gem. rating",
"countries": "Landen",
"avg_lead": "Gem. levertijd",
"name": "Bedrijfsnaam",
"country": "Land",
"category": "Categorie",
"rating": "Rating",
"moq": "MOQ",
"lead_time": "Levertijd (dagen)",
"contact": "Contact",
"cat_meat": "Vlees",
"cat_dairy": "Zuivel",
"cat_spices": "Kruiden & specerijen",
"cat_packaging": "Verpakking",
"cat_logistics": "Logistiek",
"cat_ingredients": "Ingrediënten",
"cat_other": "Overig",
"no_suppliers": "Nog geen leveranciers geregistreerd.",
"filter_country": "Filter land",
"filter_category": "Filter categorie"
}
}
+1
View File
@@ -7,4 +7,5 @@ httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
openpyxl>=3.1.0
tweepy==4.15.0
+501
View File
@@ -0,0 +1,501 @@
/* Revenue Cockpit — Excel layout (licht) */
.rc-excel-page {
--rc-green: #92d050;
--rc-red: #ff0000;
--rc-orange: #ffc000;
--rc-yellow: #ffff00;
--rc-blue: #bdd7ee;
--rc-border: #b4b4b4;
--rc-text: #000;
background: #e8e8e8;
color: var(--rc-text);
margin: -1rem -1.25rem 0;
padding: 0.75rem 1rem 2rem;
min-height: calc(100vh - 4rem);
font-family: Calibri, "Segoe UI", Arial, sans-serif;
font-size: 11pt;
}
.persona-ceo .main-topnav:has(.rc-excel-page),
.main-topnav:has(.rc-excel-page) {
background: #e8e8e8;
}
.rc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
gap: 0.5rem;
}
.rc-toolbar-title {
font-weight: 700;
font-size: 0.85rem;
color: #333;
}
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
.rc-save-hint { font-size: 0.72rem; color: #666; }
.rc-saved { color: #217346; }
.rc-btn {
border: 1px solid #aaa;
background: #fff;
border-radius: 3px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
cursor: pointer;
color: #222;
}
.rc-btn:hover { background: #f3f3f3; }
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
.rc-btn-import { font-size: 0.72rem; }
.rc-sheet-wrap {
overflow: auto;
background: #fff;
border: 1px solid #888;
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
}
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
.rc-sheet {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
}
.rc-col-logo { width: 72px; }
.rc-col-deal { width: 16%; }
.rc-col-num { width: 10%; }
.rc-col-steps { width: auto; }
.rc-row td {
border: 1px solid var(--rc-border);
vertical-align: top;
padding: 4px 6px;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
color: #000;
}
/* Header goals — geel */
.rc-row-goals td {
background: var(--rc-yellow);
font-weight: 600;
min-height: 48px;
}
.rc-cell-tagline {
font-size: 14pt;
font-style: italic;
text-align: center;
}
/* Kolomkoppen */
.rc-row-head td {
background: #fff;
font-weight: 700;
border-bottom: 2px solid #666;
}
.rc-th { text-align: left; }
.rc-num { text-align: right; }
/* Row kleuren — exact Excel */
.rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
.rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
.rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
.rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
.rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
.rc-style-white td:not(.rc-logo-cell) { background: #fff; }
.rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
.rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
.rc-cell-edit:focus {
outline: 2px solid #217346;
outline-offset: -2px;
background: #fff !important;
color: #000 !important;
}
.rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
.rc-cell-deal { font-weight: 600; }
.rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
/* Logo kolom */
.rc-logo-cell {
background: #fff !important;
text-align: center;
vertical-align: middle;
padding: 6px 4px;
width: 72px;
}
.rc-logo-cucina .rc-logo-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 6.5pt;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.02em;
text-align: center;
}
.rc-logo-foodlinkk {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
}
.rc-fl-brand {
font-size: 11pt;
font-weight: 900;
color: #111;
letter-spacing: -0.02em;
}
.rc-fl-brand::first-letter { color: #f5c400; }
.rc-fl-sub {
font-size: 5.5pt;
font-weight: 700;
letter-spacing: 0.04em;
color: #333;
text-transform: uppercase;
}
/* Rij panel */
.rc-row-panel {
position: fixed;
bottom: 1rem;
right: 1rem;
width: min(420px, calc(100vw - 2rem));
background: #fff;
border: 1px solid #888;
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
border-radius: 6px;
z-index: 100;
font-size: 0.82rem;
}
.rc-row-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
.rc-inline select, .rc-inline input {
flex: 1;
border: 1px solid #bbb;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
}
.rc-style-select {
border: 1px solid #bbb;
font-size: 0.72rem;
padding: 0.15rem 0.35rem;
}
.rc-footnote {
margin: 0.75rem 0 0;
font-size: 0.72rem;
color: #666;
}
@media (max-width: 768px) {
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
}
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
.rc-page {
--rc-green: #4ade80;
--rc-red: #f87171;
--rc-orange: #fb923c;
--rc-yellow: #facc15;
--rc-blue: #60a5fa;
--rc-green-fill: #22c55e;
--rc-red-fill: #ef4444;
--rc-orange-fill: #f59e0b;
--rc-yellow-fill: #eab308;
--rc-blue-fill: #3b82f6;
color: #e2e8f0;
}
.rc-page.rc-mode-sheet { background: transparent; }
.rc-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.rc-header h1 { margin: 0; font-size: 1.25rem; }
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.rc-view-toggle {
display: flex;
border: 1px solid rgba(148,163,184,0.2);
border-radius: 8px;
overflow: hidden;
}
.rc-toggle-btn {
border: none;
background: transparent;
color: #94a3b8;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
cursor: pointer;
}
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
.rc-summary-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
padding: 0.55rem 0.85rem;
background: rgba(15,20,25,0.6);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 10px;
}
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
.rc-sum-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; }
.rc-sum-item strong { font-size: 0.95rem; color: #34d399; font-variant-numeric: tabular-nums; }
.rc-filter-inline {
flex: 1 1 140px;
min-width: 120px;
max-width: 220px;
padding: 0.35rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(148,163,184,0.18);
background: #0a0e12;
color: #e2e8f0;
font-size: 0.78rem;
margin-left: auto;
}
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
.rc-dot-btn {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(148,163,184,0.3);
background: transparent;
cursor: pointer;
padding: 0;
}
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
.rc-dot-green { background: var(--rc-green); }
.rc-dot-red { background: var(--rc-red); }
.rc-dot-orange { background: var(--rc-orange); }
.rc-dot-yellow { background: var(--rc-yellow); }
.rc-dot-blue { background: var(--rc-blue); }
.rc-dot-white { background: #e2e8f0; }
.rc-visual-wrap {
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(148,163,184,0.12);
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
background: #0a0e12;
}
.rc-visual-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
table-layout: fixed;
background: #0a0e12;
font-family: inherit;
font-size: 0.82rem;
color: #e2e8f0;
}
.rc-col-chart-num { width: 11%; }
.rc-col-donut { width: 64px; }
.rc-visual-goals td {
background: rgba(234, 179, 8, 0.12);
color: #fde68a;
font-weight: 600;
font-size: 0.78rem;
padding: 6px 10px;
border: 1px solid rgba(148,163,184,0.1);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-vtagline { font-style: italic; text-align: center; color: #fbbf24; }
.rc-visual-head td {
background: rgba(15,20,25,0.95);
color: #64748b;
font-weight: 600;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 6px 10px;
border: 1px solid rgba(148,163,184,0.1);
border-bottom: 1px solid rgba(148,163,184,0.2);
}
.rc-visual-row { cursor: pointer; transition: background 0.12s; }
.rc-visual-row:hover td:not(.rc-logo-cell) { background: rgba(30,41,59,0.55) !important; }
.rc-visual-row.rc-row-open td:not(.rc-logo-cell) { background: rgba(30,58,95,0.35) !important; }
.rc-visual-row.rc-row-open { box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
.rc-visual-row td,
.rc-vcell {
border: 1px solid rgba(148,163,184,0.08);
padding: 6px 10px;
vertical-align: middle;
line-height: 1.35;
background: rgba(15,20,25,0.5);
}
.rc-visual-row.rc-style-green td:not(.rc-logo-cell) {
background: rgba(34, 197, 94, 0.1);
box-shadow: inset 3px 0 0 var(--rc-green-fill);
}
.rc-visual-row.rc-style-red td:not(.rc-logo-cell) {
background: rgba(239, 68, 68, 0.1);
box-shadow: inset 3px 0 0 var(--rc-red-fill);
}
.rc-visual-row.rc-style-orange td:not(.rc-logo-cell) {
background: rgba(245, 158, 11, 0.1);
box-shadow: inset 3px 0 0 var(--rc-orange-fill);
}
.rc-visual-row.rc-style-yellow td:not(.rc-logo-cell) {
background: rgba(234, 179, 8, 0.1);
box-shadow: inset 3px 0 0 var(--rc-yellow-fill);
font-weight: 600;
color: #fde68a;
}
.rc-visual-row.rc-style-blue td:not(.rc-logo-cell) {
background: rgba(59, 130, 246, 0.1);
box-shadow: inset 3px 0 0 var(--rc-blue-fill);
}
.rc-visual-row.rc-style-white td:not(.rc-logo-cell) {
background: rgba(15,20,25,0.5);
box-shadow: inset 3px 0 0 #475569;
}
.rc-brand-cucina .rc-vdeal { padding-left: 8px; }
.rc-brand-foodlinkk .rc-vdeal { padding-left: 8px; }
.rc-logo-mini { width: 36px; padding: 2px !important; text-align: center; background: transparent !important; }
.rc-logo-xs .rc-logo-ring { width: 26px; height: 26px; font-size: 11px; font-weight: 900; }
.rc-logo-xs.rc-logo-foodlinkk .rc-fl-brand { font-size: 14px; }
.rc-vdeal { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #f1f5f9; }
.rc-vnum { text-align: right; }
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.8rem; font-weight: 600; color: #34d399; }
.rc-mini-bar {
height: 4px;
background: rgba(148,163,184,0.15);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.rc-mini-bar-fill { height: 100%; border-radius: 2px; min-width: 2px; }
.rc-mini-bar-year { background: #38bdf8 !important; }
.rc-vdonut { text-align: center; padding: 4px !important; }
.rc-donut {
width: 36px; height: 36px;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.rc-donut-label { font-size: 0.58rem; font-weight: 700; color: #e2e8f0; z-index: 1; }
.rc-donut-empty { color: #64748b; font-size: 0.75rem; }
.rc-vsteps {
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 4px; }
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
.rc-expand-cell { padding: 0 !important; }
.rc-expand-inner {
display: flex;
gap: 1rem;
padding: 0.85rem 1rem;
background: rgba(10,14,18,0.98);
color: #e2e8f0;
border-top: 1px solid rgba(56,189,248,0.15);
}
.rc-expand-chart { flex: 0 0 140px; }
.rc-bar-chart {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.5rem;
height: 72px;
margin-bottom: 0.5rem;
}
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
.rc-bar-stack {
width: 28px;
min-height: 2px;
background: var(--rc-green-fill);
border-radius: 3px 3px 0 0;
transition: height 0.2s;
}
.rc-bar-year { background: #38bdf8; }
.rc-expand-stats { font-size: 0.75rem; }
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
.rc-expand-stats span { color: #64748b; }
.rc-expand-stats strong { color: #34d399; }
.rc-expand-body { flex: 1; min-width: 0; }
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
.rc-expand-body p { margin: 0; font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; color: #cbd5e1; }
.rc-expand-task { margin-top: 0.65rem; }
.rc-expand-task .rc-inline select,
.rc-expand-task .rc-inline input {
border: 1px solid rgba(148,163,184,0.25);
background: #0f1419;
color: #e2e8f0;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
border-radius: 4px;
}
.rc-page .rc-excel-page {
margin: 0;
padding: 0;
min-height: auto;
background: #e8e8e8;
color: #000;
border-radius: 8px;
overflow: hidden;
}
@media (max-width: 768px) {
.rc-summary-strip { gap: 0.5rem; }
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
.rc-expand-inner { flex-direction: column; }
}
+422
View File
@@ -0,0 +1,422 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, rgba(148,163,184,0.15) ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+269
View File
@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=5" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
<header class="rc-header">
<div>
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
<p class="rc-subtitle" x-show="meta.file_mtime">
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
· <span x-text="fmtDate(meta.file_mtime)"></span>
</p>
</div>
<div class="rc-header-actions">
<div class="rc-view-toggle">
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
</div>
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
</div>
</header>
<!-- Cockpit: Excel-layout met charts -->
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
<div class="rc-summary-strip">
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / mnd</span>
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / jr</span>
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Deals</span>
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
</div>
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
<div class="rc-style-dots">
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
<template x-for="s in styleFilters" :key="'d-' + s.id">
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
</template>
</div>
</div>
<div class="rc-visual-wrap">
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-chart-num" />
<col class="rc-col-chart-num" />
<col class="rc-col-donut" />
<col class="rc-col-steps" />
</colgroup>
<!-- Excel rij 1: goals -->
<tr class="rc-visual-goals">
<td class="rc-logo-cell"></td>
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
</tr>
<tr class="rc-visual-head">
<td class="rc-logo-cell"></td>
<td class="rc-vcell">Deal</td>
<td class="rc-vcell rc-num">Marge mnd</td>
<td class="rc-vcell rc-num">Marge jr</td>
<td class="rc-vcell rc-num">Aandeel</td>
<td class="rc-vcell">Next steps</td>
</tr>
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
<tr class="rc-visual-row"
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<td class="rc-logo-cell rc-logo-mini">
<template x-if="row.logoCell === 'cucina'">
<div class="rc-logo rc-logo-cucina rc-logo-xs"><span class="rc-logo-ring">C</span></div>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<div class="rc-logo rc-logo-foodlinkk rc-logo-xs"><span class="rc-fl-brand">F</span></div>
</template>
</td>
<td class="rc-vcell rc-vdeal" x-text="row.name"></td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_month">
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_year">
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
</div>
</td>
<td class="rc-vcell rc-vdonut">
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
</div>
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
</td>
<td class="rc-vcell rc-vsteps">
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
</td>
</tr>
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
<td class="rc-expand-cell" colspan="6">
<div class="rc-expand-inner">
<div class="rc-expand-chart">
<div class="rc-bar-chart">
<div class="rc-bar-col">
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
<span>Mnd</span>
</div>
<div class="rc-bar-col">
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
<span>Jr</span>
</div>
</div>
<div class="rc-expand-stats">
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
</div>
</div>
<div class="rc-expand-body">
<h4>Next steps</h4>
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
<div class="rc-expand-task" x-show="row.db_id">
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</table>
</div>
</div>
<!-- Bewerken: originele Excel -->
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
<div class="rc-toolbar">
<span class="rc-toolbar-title">Bewerken · DB</span>
<div class="rc-toolbar-actions">
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
</div>
</div>
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
<table class="rc-sheet" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-num" />
<col class="rc-col-num" />
<col class="rc-col-steps" />
</colgroup>
<tr class="rc-row rc-row-goals">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.vision_text || ''"
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.horizon_text || ''"
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.mid_text || ''"
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
x-init="$el.textContent = goals.tagline || ''"
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
</tr>
<tr class="rc-row rc-row-head">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-th">Deal</td>
<td class="rc-cell rc-th rc-num">Marge Month</td>
<td class="rc-cell rc-th rc-num">Marge Year</td>
<td class="rc-cell rc-th">Next steps</td>
</tr>
<template x-for="row in sheetRows" :key="row.id || row.source_row">
<tr class="rc-row rc-data-row"
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
@click="selectRow(row)">
<template x-if="row.logoCell === 'cucina'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
</td>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-foodlinkk">
<span class="rc-fl-brand">Foodlinkk</span>
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
</div>
</td>
</template>
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.name || ''"
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_month)"
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_year)"
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.next_steps || ''"
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
</tr>
</template>
</table>
</div>
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
<div class="rc-row-panel-head">
<strong x-text="selectedRow?.name"></strong>
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
<option value="green">Groen (live)</option>
<option value="red">Rood (inactief)</option>
<option value="orange">Oranje</option>
<option value="yellow">Geel (sectie)</option>
<option value="blue">Blauw</option>
<option value="white">Wit</option>
</select>
</div>
<div class="rc-row-panel-body">
<label>Agent taak
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
</div>
</label>
</div>
</div>
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=4"></script>
{% endblock %}
+469 -55
View File
@@ -1,103 +1,517 @@
.mesh-wrap {
position: relative;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(7, 11, 18, 0.95) 0%, rgba(15, 23, 42, 0.85) 100%);
overflow: hidden;
transition: box-shadow 0.25s ease, border-color 0.25s ease;
}
.mesh-wrap-mode-mesh { border-color: rgba(168, 85, 247, 0.25); }
.mesh-wrap-mode-org { border-color: rgba(255, 215, 0, 0.2); }
.mesh-wrap-mode-pulse { border-color: rgba(34, 197, 94, 0.25); }
.mesh-wrap-mode-clusters { border-color: rgba(255, 159, 67, 0.25); }
.mesh-wrap-mode-pyramid { border-color: rgba(0, 229, 255, 0.2); }
.mesh-svg {
width: 100%;
min-height: 520px;
min-height: 680px;
display: block;
}
.mesh-tier-band {
fill: rgba(148, 163, 184, 0.03);
stroke: none;
}
.mesh-tier-0 { fill: rgba(0, 229, 255, 0.03); }
.mesh-tier-1 { fill: rgba(168, 85, 247, 0.025); }
.mesh-tier-2 { fill: rgba(34, 197, 94, 0.025); }
.mesh-edge {
stroke: rgba(0, 229, 255, 0.42);
stroke-width: 1.6;
fill: none;
stroke: rgba(100, 116, 139, 0.2);
stroke-linecap: round;
stroke-width: 1.2;
pointer-events: none;
}
.mesh-edge-report {
stroke: rgba(0, 229, 255, 0.45);
stroke-width: 1.5;
}
.mesh-edge-delegate {
stroke: rgba(255, 215, 0, 0.4);
stroke-width: 1.3;
stroke-dasharray: 5 6;
}
.mesh-edge-executive {
stroke: rgba(255, 159, 67, 0.28);
stroke-width: 1.1;
}
.mesh-edge-peer {
stroke: rgba(168, 85, 247, 0.12);
stroke-width: 0.8;
}
.mesh-edge-peer-live {
stroke: rgba(192, 132, 252, 0.55);
stroke-width: 1.4;
}
.mesh-pulse {
stroke: rgba(0, 229, 255, 0.9);
stroke-width: 3;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 3 140;
animation: mesh-pulse 2.4s linear infinite;
stroke-dasharray: 4 100;
animation: mesh-pulse 2.6s linear infinite;
pointer-events: none;
}
.mesh-pulse-active.mesh-edge-report { stroke: rgba(0, 229, 255, 0.85); }
.mesh-pulse-active.mesh-edge-delegate { stroke: rgba(255, 215, 0, 0.75); }
.mesh-pulse-active.mesh-edge-peer-live { stroke: rgba(192, 132, 252, 0.9); }
@keyframes mesh-pulse {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -286; }
to { stroke-dashoffset: -208; }
}
.mesh-node { cursor: pointer; }
.mesh-node text {
font-size: 12px;
fill: #e2e8f0;
font-weight: 600;
.mesh-node text.mesh-label {
font-size: 13px;
fill: #f1f5f9;
font-weight: 700;
text-anchor: middle;
pointer-events: none;
}
.mesh-label-agent { font-size: 13px; }
.mesh-label-herman { font-size: 14px; fill: #ffd700; }
.mesh-label-exec { font-size: 13px; }
.mesh-node text.mesh-sublabel {
font-size: 10px;
fill: #94a3b8;
text-anchor: middle;
pointer-events: none;
}
.mesh-sublabel-herman { fill: #cbd5e1; font-size: 10px; }
.mesh-node text.mesh-context {
font-size: 9.5px;
fill: #64748b;
text-anchor: middle;
pointer-events: none;
}
.mesh-node text.mesh-domain {
font-size: 8.5px;
fill: #475569;
text-anchor: middle;
pointer-events: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mesh-node text.mesh-emoji {
font-size: 14px;
text-anchor: middle;
pointer-events: none;
}
.mesh-herman-crown { font-size: 18px; pointer-events: none; }
.mesh-node circle {
fill: #0f172a;
stroke: rgba(148, 163, 184, 0.65);
.mesh-node circle.main {
fill: #0b1220;
stroke: rgba(148, 163, 184, 0.55);
stroke-width: 2;
}
.mesh-node.healthy circle { stroke: #22c55e; }
.mesh-node.warn circle { stroke: #f59e0b; }
.mesh-node.idle circle { stroke: #38bdf8; }
.mesh-node.offline circle { stroke: #64748b; }
.mesh-node.healthy circle.main { stroke: #22c55e; }
.mesh-node.warn circle.main { stroke: #f59e0b; }
.mesh-node.idle circle.main { stroke: #475569; }
.mesh-node.offline circle.main { stroke: #334155; opacity: 0.65; }
.mesh-herman circle.main {
fill: #111827;
stroke: #ffd700;
stroke-width: 4;
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
}
.mesh-exec circle.main {
fill: #0f172a;
stroke-width: 3;
}
.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
.mesh-exec circle.ring {
.mesh-agent-live .mesh-pulse-wrap circle.pulse-ring {
fill: none;
stroke: rgba(255, 255, 255, 0.15);
stroke-width: 1.5;
stroke-dasharray: 4 4;
}
.mesh-agent .mesh-status { fill: #64748b; }
.mesh-herman circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.35);
stroke-width: 2;
stroke-dasharray: 8 4;
animation: mesh-herman-ring 7s linear infinite;
stroke: rgba(34, 197, 94, 0.45);
stroke-width: 1.8;
animation: mesh-agent-pulse 2.8s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
@keyframes mesh-herman-ring {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@keyframes mesh-agent-pulse {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.75; transform: scale(1.12); }
}
.mesh-herman circle.main {
fill: #0f172a;
stroke: #ffd700;
stroke-width: 3;
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
}
.mesh-herman.active .mesh-herman-ring circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.4);
stroke-width: 2;
stroke-dasharray: 10 8;
}
.mesh-status-live circle.main { stroke-width: 2.5; }
.mesh-status-ready circle.main { stroke: #64748b; }
.mesh-status-warn circle.main { stroke: #f59e0b; }
.mesh-status-idle circle.main { stroke: #334155; opacity: 0.7; }
.mesh-exec circle.main { stroke-width: 2.5; }
.mesh-ceo circle.main { stroke: #ff9f43; }
.mesh-cto circle.main { stroke: #a855f7; }
/* ——— Communicatie-modus kiezer ——— */
.mesh-mode-section {
margin-bottom: 1rem;
}
.mesh-mode-section > label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
margin-bottom: 0.55rem;
font-weight: 600;
}
.mesh-mode-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 1100px) {
.mesh-mode-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
.mesh-mode-grid { grid-template-columns: repeat(2, 1fr); }
}
.mesh-mode-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
padding: 0.7rem 0.75rem 0.65rem;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(145deg, rgba(15, 23, 42, 0.7) 0%, rgba(7, 11, 18, 0.9) 100%);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
min-height: 5.5rem;
color: inherit;
font: inherit;
width: 100%;
}
.mesh-mode-card:hover {
border-color: rgba(148, 163, 184, 0.35);
transform: translateY(-1px);
}
.mesh-mode-card.is-active {
border-color: rgba(0, 229, 255, 0.55);
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 4px 20px rgba(0, 229, 255, 0.08);
background: linear-gradient(145deg, rgba(0, 229, 255, 0.06) 0%, rgba(7, 11, 18, 0.95) 100%);
}
.mesh-mode-card[data-mode="mesh"].is-active {
border-color: rgba(168, 85, 247, 0.55);
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15), 0 4px 20px rgba(168, 85, 247, 0.08);
}
.mesh-mode-card[data-mode="org"].is-active {
border-color: rgba(255, 215, 0, 0.45);
box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.12), 0 4px 20px rgba(255, 215, 0, 0.06);
}
.mesh-mode-card[data-mode="pulse"].is-active {
border-color: rgba(34, 197, 94, 0.5);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 4px 20px rgba(34, 197, 94, 0.08);
}
.mesh-mode-card[data-mode="clusters"].is-active {
border-color: rgba(255, 159, 67, 0.5);
box-shadow: 0 0 0 1px rgba(255, 159, 67, 0.12), 0 4px 20px rgba(255, 159, 67, 0.08);
}
.mesh-mode-icon {
font-size: 1.15rem;
line-height: 1;
opacity: 0.85;
}
.mesh-mode-title {
font-size: 0.82rem;
font-weight: 700;
color: #f1f5f9;
line-height: 1.2;
}
.mesh-mode-sub {
font-size: 0.68rem;
color: #64748b;
line-height: 1.2;
}
.mesh-mode-desc {
font-size: 0.65rem;
color: #94a3b8;
line-height: 1.35;
margin-top: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mesh-mode-check {
position: absolute;
top: 0.45rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 1.5px solid rgba(148, 163, 184, 0.35);
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.mesh-mode-card.is-active .mesh-mode-check {
opacity: 1;
background: #00e5ff;
border-color: #00e5ff;
box-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
}
.mesh-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
margin-bottom: 0.65rem;
}
.mesh-mode-badge {
margin-right: auto;
font-size: 0.78rem;
font-weight: 700;
color: #00e5ff;
padding: 0.25rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
}
.mesh-framework-head { margin-bottom: 0.5rem; }
.mesh-framework-head h2 { margin: 0; }
.mesh-legend {
display: flex;
gap: 0.8rem;
gap: 0.65rem 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
margin-top: 0.65rem;
color: #94a3b8;
font-size: 0.78rem;
}
.mesh-legend span::before {
content: '';
display: inline-block;
width: 1rem;
height: 2px;
margin-right: 0.3rem;
vertical-align: middle;
border-radius: 2px;
}
.mesh-legend .leg-report::before { background: #00e5ff; }
.mesh-legend .leg-delegate::before { background: #ffd700; }
.mesh-legend .leg-peer::before { background: #a855f7; }
.mesh-legend .leg-exec::before { background: #ff9f43; }
.mesh-cluster-label {
font-size: 11px;
fill: #94a3b8;
font-weight: 600;
pointer-events: none;
}
.mesh-agent-cluster-growth circle.main { stroke: rgba(0, 229, 255, 0.7); }
.mesh-agent-cluster-product circle.main { stroke: rgba(168, 85, 247, 0.7); }
.mesh-agent-cluster-ops circle.main { stroke: rgba(34, 197, 94, 0.65); }
.mesh-edge-straight { stroke-dasharray: none; }
/* ——— Terminals grid ——— */
.agents-terminals-intro {
margin: 0 0 1rem;
color: #94a3b8;
font-size: 0.88rem;
}
.agents-terminals-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.85rem;
}
.agent-term-window {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 12px;
background: #060a10;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 200px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.agent-term-window.is-live {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
}
.agent-term-window.is-expanded {
grid-column: 1 / -1;
min-height: 320px;
}
.agent-term-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: rgba(15, 23, 42, 0.85);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
font-size: 0.82rem;
}
.agent-term-head .term-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
}
.agent-term-window.is-live .term-dot {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
animation: term-dot-blink 1.8s ease-in-out infinite;
}
@keyframes term-dot-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.agent-term-head strong { color: #f1f5f9; flex: 1; }
.agent-term-head small { color: #64748b; font-size: 0.72rem; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-term-body {
flex: 1;
overflow-y: auto;
max-height: 180px;
padding: 0.5rem 0.65rem;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
line-height: 1.45;
}
.agent-term-window.is-expanded .agent-term-body {
max-height: 280px;
}
.term-line {
padding: 0.2rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
display: flex;
gap: 0.5rem;
}
.term-ts { color: #64748b; flex-shrink: 0; min-width: 2.5rem; }
.term-msg { color: #cbd5e1; word-break: break-word; }
.term-line.line-handoff .term-msg { color: #c4b5fd; }
.term-line.line-action .term-msg { color: #86efac; }
.term-line.line-command .term-msg { color: #86efac; font-weight: 600; }
.term-line.line-output .term-msg { color: #e2e8f0; }
.term-line.line-error .term-msg { color: #fca5a5; }
.term-line.term-idle .term-msg { color: #64748b; font-style: italic; }
.term-line.term-idle code { color: #94a3b8; font-size: 0.95em; }
.agent-term-foot {
border-top: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(10, 14, 20, 0.95);
padding: 0.45rem 0.55rem;
}
.agent-term-cmd {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
}
.term-prompt {
color: #22c55e;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
font-weight: 700;
flex-shrink: 0;
user-select: none;
}
.agent-term-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: #f1f5f9;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
font-size: 0.72rem;
outline: none;
padding: 0.2rem 0;
}
.agent-term-input::placeholder { color: #475569; }
.agent-term-input.is-busy { opacity: 0.55; }
.agent-term-window.is-running .term-dot {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.7);
}
.agent-term-window.is-expanded .agent-term-foot {
padding: 0.55rem 0.75rem;
}
.agent-term-window.is-expanded .agent-term-input {
font-size: 0.78rem;
}
.mesh-terminal-focus {
margin-top: 1rem;
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 12px;
background: #060a10;
}
.mesh-terminal-focus .agent-term-body {
max-height: 240px;
}
.vtab-badge {
display: inline-flex;
min-width: 1.1rem;
+177
View File
@@ -0,0 +1,177 @@
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.app-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: 0.75rem 0.6rem;
background: #080c12;
border-right: 1px solid rgba(148, 163, 184, 0.1);
}
.app-sidebar-brand {
padding: 0.25rem 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.app-sidebar-brand .brand-title {
font-size: 0.58rem;
letter-spacing: 0.14em;
color: #64748b;
margin: 0;
}
.app-sidebar-brand .brand-name {
font-size: 0.95rem;
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.58rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
}
.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
.persona-nav { display: contents; }
.app-sidebar-group {
margin-bottom: 0.65rem;
}
.app-sidebar-label {
font-size: 0.56rem;
letter-spacing: 0.12em;
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.5rem 0.55rem;
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.78rem;
font-weight: 500;
text-decoration: none;
text-align: left;
white-space: nowrap;
transform: none !important;
animation: none !important;
box-shadow: none !important;
text-shadow: none !important;
overflow: visible;
isolation: auto;
}
.app-sidebar a.nav-pill::before,
.app-sidebar a.nav-pill::after {
display: none !important;
content: none !important;
}
.app-sidebar a.nav-pill:hover {
background: rgba(30, 41, 59, 0.8);
border-color: rgba(148, 163, 184, 0.2);
color: #f1f5f9 !important;
transform: none !important;
}
.app-sidebar a.nav-pill.active {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
border-left: 3px solid var(--nav-accent);
padding-left: calc(0.55rem - 2px);
color: #fff !important;
box-shadow: none !important;
animation: none !important;
}
.sidebar-nav-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.85rem;
background: rgba(8, 12, 18, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
.sidebar-nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Per-item accent colors */
.app-sidebar a.nav-pill-wereldexport { --nav-accent: #0ea5e9; }
.app-sidebar a.nav-pill-clients { --nav-accent: #38bdf8; }
.app-sidebar a.nav-pill-deals { --nav-accent: #4ade80; }
.app-sidebar a.nav-pill-products { --nav-accent: #a855f7; }
.app-sidebar a.nav-pill-suppliers { --nav-accent: #fb923c; }
.app-sidebar a.nav-pill-chat { --nav-accent: #fcd34d; }
.app-sidebar a.nav-pill-reports { --nav-accent: #6366f1; }
.app-sidebar a.nav-pill-herman { --nav-accent: #fbbf24; }
.app-sidebar a.nav-pill-retail { --nav-accent: #0066cc; }
.app-sidebar a.nav-pill-marketing { --nav-accent: #ff6b9d; }
.app-sidebar a.nav-pill-beurs { --nav-accent: #b8ff3c; }
.app-sidebar a.nav-pill-documents { --nav-accent: #94a3b8; }
.app-sidebar a.nav-pill-telegram { --nav-accent: #2aabee; }
.app-sidebar a.nav-pill-analytics { --nav-accent: #8b5cf6; }
.app-sidebar a.nav-pill-agents { --nav-accent: #00e5ff; }
.app-sidebar a.nav-pill-browser { --nav-accent: #14b8a6; }
.app-sidebar a.nav-pill-studio { --nav-accent: #ec4899; }
.app-sidebar a.nav-pill-voice { --nav-accent: #f97316; }
.app-sidebar a.nav-pill-ops { --nav-accent: #22d3ee; }
.app-sidebar a.nav-pill-settings { --nav-accent: #e2e8f0; }
.persona-badge {
display: inline-block;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.08em;
padding: 0.15rem 0.45rem;
border-radius: 6px;
margin-right: 0.45rem;
}
.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
+220 -7
View File
@@ -11,15 +11,60 @@
.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
.clients-tabs {
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.clients-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.88);
border: 1px solid rgba(56, 189, 248, 0.14);
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.clients-tab {
border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
white-space: nowrap;
}
.clients-tab-icon {
font-size: 0.9rem;
line-height: 1;
opacity: 0.85;
}
.clients-tab:hover {
color: #e2e8f0;
background: rgba(56, 189, 248, 0.07);
}
.clients-tab.active {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(168, 85, 247, 0.1));
border-color: rgba(56, 189, 248, 0.38);
box-shadow: 0 0 18px rgba(56, 189, 248, 0.1);
}
.clients-tab.active .clients-tab-icon { opacity: 1; }
.clients-tab-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
@media (max-width: 720px) {
.clients-tabs { flex-direction: column; align-items: stretch; }
.clients-tab-bar { width: 100%; }
.clients-tab-actions { margin-left: 0; width: 100%; }
}
.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
@@ -39,7 +84,30 @@
.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; margin-bottom: 0.45rem; }
.clients-card-stage {
width: 100%;
margin-top: 0.15rem;
padding: 0.28rem 0.45rem;
font-size: 0.68rem;
font-weight: 600;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(0, 0, 0, 0.35);
color: #e2e8f0;
cursor: pointer;
}
.clients-card-stage:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15); }
.clients-inline-stage {
min-width: 7.5rem;
padding: 0.25rem 0.4rem;
font-size: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(0, 0, 0, 0.35);
color: #e2e8f0;
}
.clients-inline-stage:focus { outline: none; border-color: #38bdf8; }
.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
.stage-intake { border-left: 3px solid #94a3b8; }
@@ -76,6 +144,103 @@
display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
}
@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
.store-filters-card {
background: rgba(13, 18, 25, 0.75);
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
padding: 0.85rem 1rem;
margin-bottom: 1rem;
}
.store-filters-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 1100px) { .store-filters-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) { .store-filters-grid { grid-template-columns: 1fr 1fr; } }
.store-filter-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.store-filter-field .filter-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.store-filter-search { grid-column: span 2; }
@media (max-width: 640px) { .store-filter-search { grid-column: span 2; } }
.store-filters-extra {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.store-filters-extra summary {
cursor: pointer;
font-size: 0.78rem;
color: #7dd3fc;
user-select: none;
margin-bottom: 0.45rem;
}
.store-filter-checks {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1rem;
font-size: 0.78rem;
color: #cbd5e1;
}
.store-filter-checks label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.store-filters-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.store-results-meta {
margin-left: auto;
font-size: 0.72rem;
color: #94a3b8;
}
.store-results-col { min-width: 0; }
.store-table-wrap {
max-height: 420px;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
}
.store-results-table { font-size: 0.78rem; margin: 0; }
.store-results-table th {
position: sticky;
top: 0;
background: #0d1219;
z-index: 1;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
}
.store-table-row { cursor: pointer; }
.store-table-row:hover td { background: rgba(56, 189, 248, 0.06); }
.store-table-row.selected td {
background: rgba(56, 189, 248, 0.12);
box-shadow: inset 3px 0 0 #38bdf8;
}
.store-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 0.55rem;
font-size: 0.78rem;
}
.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.store-list-item {
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
@@ -91,3 +256,51 @@
.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
/* ── 360° Klant (CRM) ── */
.crm-360-shell {
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
min-height: 560px;
}
.crm-360-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(56, 189, 248, 0.06), transparent 70%);
}
.crm-360-head h2 { margin: 0; font-size: 1.05rem; color: #f1f5f9; }
.crm-360-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
.crm-360-section {
margin: 0 0 0.5rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
}
.crm-360-stat {
padding: 0.65rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(0, 0, 0, 0.25);
text-align: center;
}
.crm-360-stat .n { font-size: 1.2rem; font-weight: 800; color: #f1f5f9; }
.crm-360-stat .l { font-size: 0.6rem; color: #64748b; text-transform: uppercase; }
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
.a360-links { max-height: 200px; overflow-y: auto; }
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); color: #cbd5e1; }
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(56,189,248,0.12); color: #7dd3fc; }
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
+877
View File
@@ -0,0 +1,877 @@
/* Documents & NAS Workbench v2 */
[x-cloak] { display: none !important; }
.doc-hub {
--doc-accent: #00e5ff;
--doc-gold: #ffd700;
--doc-purple: #a855f7;
--doc-border: rgba(0, 229, 255, 0.14);
--doc-text: #f1f5f9;
--doc-muted: #94a3b8;
--doc-dim: #64748b;
}
.doc-hub .page-header h1 {
font-size: 1.65rem;
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Tabs */
.doc-nav {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding: 0.35rem;
background: rgba(6, 10, 18, 0.85);
border: 1px solid var(--doc-border);
border-radius: 14px;
}
.doc-nav-btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.1rem;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--doc-muted);
transition: all 0.18s;
user-select: none;
}
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
.doc-nav-btn.active,
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"] {
color: #e0f2fe;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
}
/* Workbench shell */
.doc-workbench {
border-radius: 16px;
border: 1px solid var(--doc-border);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
}
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
.doc-tab-input {
position: fixed;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
#doc-tab-stage {
min-height: 50vh;
}
#doc-tab-stage [data-tab-panel] {
display: none !important;
}
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
#doc-tab-stage [data-tab-panel].tab-active {
display: block !important;
min-height: 50vh;
}
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
font-size: 0.65rem;
color: var(--doc-dim);
white-space: nowrap;
}
.dl-version-bar {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
}
.doc-row-fallback { opacity: 0.85; }
.share-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
margin-bottom: 0.5rem;
font-size: 0.68rem;
}
.share-crumb {
background: transparent;
border: none;
color: #7dd3fc;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 4px;
}
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
.share-crumb-sep { color: #475569; }
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
.share-browser-list { max-height: 52vh; overflow-y: auto; }
.doc-wb-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
}
.doc-wb-head h2 {
margin: 0;
font-size: 1.05rem;
color: var(--doc-text);
}
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
.doc-kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
.doc-kpi {
padding: 0.9rem 1.1rem;
border-right: 1px solid rgba(148, 163, 184, 0.06);
cursor: pointer;
transition: background 0.15s;
}
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
.doc-kpi:last-child { border-right: none; }
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
.doc-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
/* Split layout */
.doc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
min-height: 280px;
}
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
.doc-split-col {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
.doc-split-col:last-child { border-right: none; }
.doc-chart-wrap { height: 140px; position: relative; }
/* Document table */
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
.doc-table-wrap .data-table { font-size: 0.78rem; }
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
/* Label chips */
.doc-chip {
display: inline-block;
padding: 0.18rem 0.55rem;
margin: 0.12rem 0.12rem 0 0;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
background: rgba(0, 229, 255, 0.12);
color: #7dd3fc;
border: 1px solid rgba(0, 229, 255, 0.25);
white-space: nowrap;
}
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
/* Photo grid */
.photo-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.photo-tile {
border: 1px solid var(--doc-border);
border-radius: 14px;
overflow: hidden;
background: rgba(10, 15, 26, 0.95);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.photo-tile:hover {
transform: translateY(-2px);
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
}
.photo-tile img {
width: 100%;
height: 150px;
object-fit: cover;
background: #060a10;
display: block;
}
.photo-tile-body {
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.photo-tile-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
}
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
.photo-tile-labels {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-height: 1.5rem;
align-items: flex-start;
}
.photo-tile-ocr {
font-size: 0.68rem;
line-height: 1.4;
color: var(--doc-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.8em;
}
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
/* Label filter chips row */
.label-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0 1rem 0.75rem;
}
.label-filter-chip {
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.35);
background: transparent;
color: var(--doc-muted);
}
.label-filter-chip.active, .label-filter-chip:hover {
border-color: rgba(0, 229, 255, 0.4);
color: #e0f2fe;
background: rgba(0, 229, 255, 0.1);
}
/* ── Label modal (fixed overlap + dark text) ── */
.label-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(6px);
display: none;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.label-modal-backdrop.open {
display: flex;
}
.label-modal {
width: min(780px, 100%);
max-height: 92vh;
overflow-y: auto;
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: 20px;
padding: 1.35rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
color: var(--doc-text);
}
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
.label-modal-preview {
display: grid;
grid-template-columns: 140px 1fr;
gap: 1rem;
margin: 1rem 0;
padding: 0.85rem;
background: rgba(0, 0, 0, 0.35);
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
}
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
.label-modal-preview img {
width: 100%;
border-radius: 10px;
object-fit: cover;
max-height: 160px;
border: 1px solid rgba(148, 163, 184, 0.15);
}
.modal-ocr-box {
font-size: 0.74rem;
line-height: 1.55;
color: #cbd5e1 !important;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 8px;
padding: 0.65rem 0.75rem;
max-height: 140px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.modal-ocr-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
margin-bottom: 0.35rem;
}
.label-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 0.5rem;
}
.label-pick {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
min-height: 92px;
border-radius: 12px;
border: 1px solid rgba(100, 116, 139, 0.35);
background: rgba(15, 23, 42, 0.7);
cursor: pointer;
transition: all 0.15s;
color: #e2e8f0;
}
.label-pick:hover {
border-color: rgba(0, 229, 255, 0.4);
background: rgba(0, 229, 255, 0.06);
}
.label-pick.selected {
border-color: var(--pick-color, #00e5ff);
background: rgba(0, 229, 255, 0.14);
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
}
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
.label-pick-name {
font-size: 0.68rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
color: #cbd5e1;
display: block;
width: 100%;
word-break: break-word;
}
.label-pick.selected .label-pick-name { color: #f8fafc; }
.label-modal-extra {
margin-top: 1rem;
display: grid;
gap: 0.5rem;
}
.label-modal-extra label {
font-size: 0.72rem;
color: var(--doc-muted);
display: block;
margin-bottom: 0.25rem;
}
.label-modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
/* Document label drawer */
.doc-label-drawer {
margin: 1rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid rgba(0, 229, 255, 0.2);
background: rgba(0, 0, 0, 0.25);
}
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
/* ── Docling workbench ── */
.dl-workbench { min-height: 620px; }
.dl-body {
display: grid;
grid-template-columns: 260px 300px 1fr;
min-height: 560px;
}
/* v3 split workbench: files | editor+preview */
.dl-body-v3 {
display: grid;
grid-template-columns: minmax(260px, 300px) 1fr;
min-height: 580px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
.dl-pipeline-panel {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.18);
}
.dl-pipeline-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
.dl-workspace {
display: flex;
flex-direction: column;
min-height: 580px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.dl-workspace.empty { justify-content: center; align-items: center; }
.dl-workspace-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.dl-ws-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
flex-wrap: wrap;
}
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
.dl-workspace-panes {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
gap: 0;
}
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
.dl-pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(148, 163, 184, 0.06);
}
.dl-pane:last-child { border-right: none; }
.dl-pane-label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0.85rem;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--doc-dim);
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
gap: 0.5rem;
flex-wrap: wrap;
}
.dl-export-tabs-inline { margin: 0; }
.dl-editor-full {
flex: 1;
min-height: 420px;
max-height: none;
border: none;
border-radius: 0;
resize: none;
}
.dl-preview-viewport {
flex: 1;
min-height: 420px;
overflow: auto;
padding: 0.65rem;
}
.dl-preview-viewport .dl-preview-box {
min-height: 200px;
max-height: none;
height: 100%;
}
.dl-preview-img {
max-width: 100%;
max-height: 480px;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.dl-preview-iframe {
width: 100%;
height: 480px;
border: none;
border-radius: 8px;
background: #fff;
}
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
.dl-file-meta { flex: 1; min-width: 0; }
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
.dl-body-v3 .dl-sidebar {
max-height: none;
height: 100%;
border-right: 1px solid rgba(148, 163, 184, 0.08);
}
@media (max-width: 1200px) {
.dl-body-v3 { grid-template-columns: 1fr; }
.dl-body-v3 .dl-sidebar { max-height: 280px; }
}
@media (max-width: 1200px) {
.dl-body { grid-template-columns: 240px 1fr; }
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
}
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
overflow-y: auto;
max-height: 680px;
}
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
.dl-options { background: rgba(0, 0, 0, 0.12); }
.dl-section-title {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--doc-dim);
margin: 0 0 0.5rem;
}
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-ext-chip {
padding: 0.22rem 0.5rem;
border-radius: 6px;
font-size: 0.65rem;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
.dl-file-item {
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.12s;
}
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
.dl-file-item.selected {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.1);
}
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-file-badge {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.58rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
.dl-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(100, 116, 139, 0.2);
font-size: 0.76rem;
color: var(--doc-muted);
cursor: pointer;
transition: all 0.12s;
}
.dl-toggle:has(input:checked) {
border-color: rgba(0, 229, 255, 0.35);
background: rgba(0, 229, 255, 0.08);
color: #e0f2fe;
}
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
.dl-preview-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
.dl-tab {
padding: 0.32rem 0.7rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.3);
background: transparent;
color: var(--doc-muted);
}
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
.dl-preview-box {
min-height: 320px;
max-height: 480px;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.1);
border-radius: 10px;
padding: 0.85rem;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
}
.dl-preview-box.rendered { white-space: normal; }
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
.dl-stat {
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(148, 163, 184, 0.1);
}
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
.dl-history-item {
padding: 0.45rem 0.55rem;
border-radius: 8px;
font-size: 0.68rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--doc-muted);
}
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
.dl-progress {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
overflow: hidden;
margin-top: 0.5rem;
}
.dl-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00e5ff, #a855f7);
animation: dl-pulse 1.2s ease-in-out infinite;
width: 40%;
}
.dl-editor {
width: 100%;
min-height: 360px;
max-height: 520px;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.76rem;
line-height: 1.55;
background: #050810;
border: 1px solid rgba(0, 229, 255, 0.2);
border-radius: 10px;
padding: 0.85rem;
color: #e2e8f0;
resize: vertical;
}
.ppt-workbench { min-height: 560px; }
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
.ppt-editor { padding: 1rem; }
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
/* ── Document chat (RAG) ── */
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
.doc-chat-input .form-input { flex: 1; }
@keyframes dl-pulse {
0%, 100% { transform: translateX(-100%); width: 30%; }
50% { transform: translateX(200%); width: 50%; }
}
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
@@ -0,0 +1,51 @@
.doc-tabs { display:flex; gap:0.35rem; flex-wrap:wrap; margin-bottom:1rem; }
.doc-tab {
padding:0.45rem 0.9rem; border-radius:999px; font-size:0.78rem; cursor:pointer;
border:1px solid rgba(0,229,255,0.15); background:rgba(13,18,25,0.8); color:#94a3b8;
}
.doc-tab.active { background:rgba(0,229,255,0.15); color:#e0f2fe; border-color:rgba(0,229,255,0.45); }
.label-modal-backdrop {
position:fixed; inset:0; z-index:1200; background:rgba(0,0,0,0.72);
display:flex; align-items:center; justify-content:center; padding:1rem;
}
.label-modal {
width:min(720px, 100%); max-height:90vh; overflow:auto;
background:linear-gradient(160deg,#0d1219,#080c12); border:1px solid rgba(0,229,255,0.2);
border-radius:16px; padding:1.1rem; box-shadow:0 20px 60px rgba(0,0,0,0.5);
}
.label-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.85rem; }
.label-modal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:0.45rem; }
.label-pick {
display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.65rem 0.4rem;
border-radius:12px; border:1px solid rgba(100,116,139,0.35); background:rgba(15,23,42,0.6);
cursor:pointer; font-size:0.72rem; text-align:center; transition:all .15s;
}
.label-pick:hover { border-color:rgba(0,229,255,0.35); }
.label-pick.selected { border-color:var(--pick-color, #00e5ff); background:rgba(0,229,255,0.12); box-shadow:0 0 0 1px var(--pick-color, #00e5ff); }
.label-pick-emoji { font-size:1.35rem; }
.docling-grid { display:grid; grid-template-columns:280px 1fr 1fr; gap:1rem; }
@media (max-width:1100px) { .docling-grid { grid-template-columns:1fr; } }
.docling-panel { border:1px solid rgba(0,229,255,0.12); border-radius:12px; padding:0.85rem; background:rgba(13,18,25,0.85); }
.docling-file-list { max-height:420px; overflow:auto; }
.docling-file-row {
padding:0.45rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.75rem;
border:1px solid transparent; margin-bottom:0.25rem;
}
.docling-file-row:hover { background:rgba(0,229,255,0.06); }
.docling-file-row.selected { border-color:rgba(0,229,255,0.35); background:rgba(0,229,255,0.1); }
.docling-opt-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.35rem 0.75rem; font-size:0.78rem; }
.docling-export-tabs { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.65rem 0; }
.docling-export-tab {
padding:0.3rem 0.65rem; border-radius:999px; font-size:0.72rem; cursor:pointer;
border:1px solid rgba(100,116,139,0.35); background:transparent; color:#94a3b8;
}
.docling-export-tab.active { border-color:#00e5ff; color:#e0f2fe; background:rgba(0,229,255,0.12); }
.docling-preview {
max-height:360px; overflow:auto; font-size:0.74rem; line-height:1.45;
background:#060a10; border:1px solid rgba(0,229,255,0.1); border-radius:8px; padding:0.65rem;
white-space:pre-wrap; word-break:break-word;
}
.docling-preview.html-preview { white-space:normal; }
.docling-status { font-size:0.72rem; color:#94a3b8; margin-top:0.35rem; }
+56
View File
@@ -0,0 +1,56 @@
/* Wereldexport — KPIs + layout lock */
.wereldexport-workspace.vtabs-layout {
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
gap: 1rem !important;
}
.wereldexport-workspace > .vtabs-nav {
flex: 0 0 220px !important;
width: 220px !important;
max-width: 220px !important;
}
.wereldexport-workspace > .vtabs-content {
flex: 1 1 0 !important;
min-width: 0 !important;
}
.ei-neo-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
.ei-neo-kpi {
padding: 0.65rem 0.75rem;
border-radius: 10px;
background: rgba(10, 14, 20, 0.85);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
.ei-neo-ring {
width: 32px; height: 32px; border-radius: 50%;
background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
display: flex; align-items: center; justify-content: center;
}
.ei-neo-ring-inner {
width: 26px; height: 26px; border-radius: 50%;
background: #0a0e14;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
}
.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.ei-sync-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
}
.ei-sync-bar .btn { font-size: 0.72rem; }
+368
View File
@@ -0,0 +1,368 @@
/* Export Intel — page layout */
.ei-page { --ei-accent: #0ea5e9; }
.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.ei-header h1 { font-size: 1.5rem; margin: 0; }
.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
.ei-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 1rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
border: 1px solid rgba(148,163,184,0.15);
border-radius: 12px;
}
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
.ei-filter-label select,
.ei-filter-label input {
font-size: 0.82rem;
padding: 0.45rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148,163,184,0.22);
background: #0a0e12;
color: #e2e8f0;
min-width: 8rem;
}
.ei-filter-search-wrap input { width: 100%; min-width: 0; }
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); min-height: 400px; background: #0a0e14; }
.ei-map-wrap #ei-map { height: 100%; min-height: 400px; }
/* Leaflet popups — rich card */
.ei-map-wrap .leaflet-popup-content-wrapper,
.ei-map-wrap .leaflet-popup-tip {
background: #0f1724;
color: #e2e8f0;
border: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
}
.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
.ei-map-wrap .leaflet-popup-close-button {
color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
}
.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
.ei-popup-card { padding: 0.85rem 1rem 1rem; }
.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
.ei-popup-badge {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--badge-color, #38bdf8);
background: rgba(56, 189, 248, 0.12);
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
.ei-map-wrap .leaflet-popup-content .ei-popup-name {
display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
color: #f8fafc !important; line-height: 1.3;
}
.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
.ei-popup-v { color: #cbd5e1; word-break: break-word; }
.ei-popup-v a { color: #38bdf8; text-decoration: none; }
.ei-popup-v a:hover { text-decoration: underline; }
.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
.ei-popup-btn {
width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
}
.ei-popup-btn:hover { filter: brightness(1.08); }
.ei-seg-toggle {
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.ei-seg-btn {
font-size: 0.78rem;
padding: 0.35rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148,163,184,0.25);
background: rgba(15,20,25,0.85);
color: #94a3b8;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ei-seg-btn:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.45); }
.ei-seg-btn.active {
color: #f8fafc;
border-color: rgba(14,165,233,0.65);
background: rgba(14,165,233,0.18);
box-shadow: 0 0 0 1px rgba(14,165,233,0.2);
}
.ei-filter-group-maptypes { flex: 1 1 100%; max-width: 100%; }
.ei-type-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.ei-type-chip {
font-size: 0.72rem;
padding: 0.3rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(148,163,184,0.22);
background: rgba(15,20,25,0.9);
color: #94a3b8;
cursor: pointer;
white-space: nowrap;
}
.ei-type-chip:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.4); }
.ei-type-chip.active {
color: #f0fdf4;
border-color: rgba(34,197,94,0.55);
background: rgba(34,197,94,0.12);
}
.ei-filter-group-halal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.ei-filter-check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: #cbd5e1;
cursor: pointer;
}
.ei-halal-min {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
}
.ei-halal-min input[type=range] { width: 90px; }
.ei-legend-row { display: flex; flex-wrap: wrap; gap: 0.65rem 1rem; }
.ei-pin-hot .ei-pin-ring { box-shadow: 0 0 0 3px rgba(34,197,94,0.45); }
.ei-halal-markets { margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-halal-markets h4 { margin: 0 0 0.45rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.ei-halal-market-row {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 0.35rem;
align-items: center;
font-size: 0.78rem;
padding: 0.25rem 0;
}
.ei-halal-market-row:hover { color: #38bdf8; }
.ei-halal-score { color: #22c55e; font-size: 0.82rem; }
.ei-halal-pill {
display: inline-block;
min-width: 2rem;
text-align: center;
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 700;
}
.ei-halal-pill.ei-halal-hot { background: rgba(34,197,94,0.2); color: #4ade80; }
.ei-halal-pill.ei-halal-warm { background: rgba(234,179,8,0.2); color: #facc15; }
.ei-halal-pill.ei-halal-mild { background: rgba(249,115,22,0.2); color: #fb923c; }
.ei-list-focus {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
border: 1px solid rgba(34,197,94,0.35);
background: rgba(34,197,94,0.06);
}
.ei-list-focus-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.ei-list-focus-title { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f8fafc; line-height: 1.3; }
.ei-list-focus-meta { margin: 0 0 0.65rem; font-size: 0.75rem; color: #94a3b8; }
.ei-list-focus-kv { display: grid; grid-template-columns: 72px 1fr; gap: 0.3rem; font-size: 0.78rem; margin-bottom: 0.3rem; }
.ei-list-focus-kv span:first-child { color: #64748b; }
.ei-list-focus-kv a { color: #38bdf8; word-break: break-all; }
.ei-list-focus-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.65rem; }
.ei-halal-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0; }
.ei-halal-tag {
font-size: 0.68rem;
padding: 0.15rem 0.45rem;
border-radius: 6px;
background: rgba(14,165,233,0.12);
color: #7dd3fc;
border: 1px solid rgba(14,165,233,0.2);
}
.ei-row-active { background: rgba(14,165,233,0.12) !important; }
.ei-row-name { font-weight: 600; color: #e2e8f0; }
.ei-contact-cell { font-size: 0.72rem; color: #94a3b8; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ei-type-pill-sm { font-size: 0.65rem; padding: 0.1rem 0.35rem; }
.ei-contact-row-compact { margin-bottom: 0.45rem; font-size: 0.78rem; }
.ei-contact-source { display: block; font-size: 0.68rem; color: #64748b; }
.ei-drawer-coords { font-size: 0.78rem; color: #94a3b8; }
.ei-drawer-coords a { margin-left: 0.5rem; color: #38bdf8; }
.ei-selection-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin: 0.5rem 0 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(14,165,233,0.25);
background: rgba(14,165,233,0.06);
}
.ei-selection-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.ei-selection-count { font-size: 0.78rem; font-weight: 700; color: #38bdf8; }
.ei-selection-hint { font-size: 0.72rem; color: #64748b; }
.ei-th-check, .ei-td-check { width: 28px; text-align: center; }
.ei-th-fav, .ei-td-fav { width: 32px; text-align: center; }
.ei-fav-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
color: #64748b;
padding: 0;
line-height: 1;
}
.ei-fav-btn.active, .ei-fav-btn:hover { color: #facc15; }
.ei-row-selected { background: rgba(14,165,233,0.08) !important; }
.ei-row-crm .ei-row-name { padding-right: 0.25rem; }
.ei-crm-badge {
display: inline-block;
margin-left: 0.35rem;
font-size: 0.58rem;
font-weight: 800;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(34,197,94,0.2);
color: #4ade80;
vertical-align: middle;
}
.ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; }
.ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-modal-root { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.ei-modal-card {
position: relative;
z-index: 1302;
width: min(420px, 96vw);
padding: 1.25rem;
border-radius: 14px;
background: #0f1419;
border: 1px solid rgba(14,165,233,0.35);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
.ei-modal-card p { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.75rem; }
.ei-modal-note { font-size: 0.75rem !important; color: #64748b !important; }
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
/* Map pins */
.ei-pin-wrap { background: transparent !important; border: none !important; }
.ei-pin {
width: 30px; height: 30px; position: relative;
display: flex; align-items: center; justify-content: center;
}
.ei-pin-ring {
position: absolute; inset: 0; border-radius: 50%;
background: var(--pin-color);
border: 2px solid rgba(255,255,255,0.9);
box-shadow: 0 2px 10px rgba(0,0,0,0.45);
}
.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
/* Clusters */
.ei-cluster-wrap { background: transparent !important; border: none !important; }
.ei-cluster {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #0ea5e9, #0369a1);
border: 2px solid rgba(255,255,255,0.55);
box-shadow: 0 4px 16px rgba(14,165,233,0.45);
color: #fff; font-weight: 800; font-size: 0.85rem;
}
.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
.ei-map-wrap .marker-cluster-small,
.ei-map-wrap .marker-cluster-medium,
.ei-map-wrap .marker-cluster-large { background: transparent !important; }
.ei-map-wrap .marker-cluster-small div,
.ei-map-wrap .marker-cluster-medium div,
.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
.ei-map-legend {
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
}
.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
.ei-leg-pin { font-style: normal; }
.ei-map-empty { margin-bottom: 0.65rem; }
.ei-map-empty-inner {
padding: 0.85rem 1rem;
border-radius: 10px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
font-size: 0.82rem;
color: #cbd5e1;
}
.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
.ei-map-empty-inner p { margin: 0.25rem 0; }
.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-table-wrap { overflow-x: auto; max-height: 480px; }
.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
.ei-empty-cta { margin-top: 0.75rem; }
.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
.ei-type-distributor { color: #fbbf24; }
.ei-type-wholesaler { color: #eab308; }
.ei-type-contract_caterer { color: #a78bfa; }
.ei-confidence { font-size: 0.65rem; color: #64748b; }
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; }
.ei-drawer-root { position: relative; z-index: 1201; }
.ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); }
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-drawer-meta strong { color: #e2e8f0; }
.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
.ei-drawer-kv span:first-child { color: #64748b; }
.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
.ei-contact-row a { color: #38bdf8; }
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
+148
View File
@@ -0,0 +1,148 @@
/* Herman browser assistant — floating widget */
.ha-fab {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 1500;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid rgba(167, 139, 250, 0.55);
background: linear-gradient(145deg, #1e293b, #0f1419);
color: #f8fafc;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
transition: transform 0.15s, box-shadow 0.15s;
}
.ha-fab:hover { transform: scale(1.06); box-shadow: 0 10px 32px rgba(167, 139, 250, 0.25); }
.ha-fab.is-open { background: linear-gradient(145deg, #4c1d95, #1e1b4b); }
.ha-panel {
position: fixed;
right: 1rem;
bottom: 5rem;
z-index: 1499;
width: min(400px, calc(100vw - 2rem));
max-height: min(560px, calc(100vh - 7rem));
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(167, 139, 250, 0.35);
background: rgba(10, 14, 20, 0.97);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.ha-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(167, 139, 250, 0.08);
}
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
.ha-confirm {
padding: 0.5rem 0.75rem;
background: rgba(251, 191, 36, 0.1);
border-bottom: 1px solid rgba(251, 191, 36, 0.25);
font-size: 0.75rem;
color: #fde68a;
}
.ha-confirm-actions { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
.ha-log {
flex: 1;
overflow-y: auto;
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 200px;
max-height: 320px;
}
.ha-bubble {
max-width: 92%;
padding: 0.5rem 0.7rem;
border-radius: 10px;
font-size: 0.8rem;
line-height: 1.4;
}
.ha-bubble-user {
align-self: flex-end;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.28);
}
.ha-bubble-agent {
align-self: flex-start;
background: rgba(167, 139, 250, 0.12);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.ha-bubble-meta { font-size: 0.62rem; color: #64748b; margin-bottom: 0.15rem; }
.ha-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.ha-preview {
margin: 0 0.65rem;
padding: 0.4rem 0.55rem;
border-radius: 8px;
font-size: 0.72rem;
color: #94a3b8;
border: 1px dashed rgba(167, 139, 250, 0.3);
min-height: 1.5rem;
}
.ha-preview.is-interim { font-style: italic; }
.ha-input-row {
display: flex;
gap: 0.35rem;
padding: 0.55rem 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
align-items: center;
}
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
.ha-mic {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(167, 139, 250, 0.45);
background: rgba(15, 20, 25, 0.95);
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
}
.ha-mic.is-listening { border-color: #f87171; animation: ha-pulse 1s infinite; }
.ha-mic.is-busy { border-color: #38bdf8; }
@keyframes ha-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15); }
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); }
}
.ha-status { font-size: 0.65rem; color: #64748b; padding: 0 0.65rem 0.35rem; }
.ha-results-modal { z-index: 1600; }
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
.ei-modal-root {
position: fixed; inset: 0; z-index: 1600;
display: flex; align-items: center; justify-content: center;
padding: 1rem; background: rgba(0, 0, 0, 0.55);
}
.ei-modal-card {
background: #0f1419; border-radius: 14px; padding: 1.25rem;
border: 1px solid rgba(14, 165, 233, 0.25);
max-width: 96vw; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
.voice-results-table th, .voice-results-table td { padding: 0.4rem 0.55rem; border-top: 1px solid rgba(148,163,184,0.1); font-size: 0.78rem; }
.voice-results-table a { color: #38bdf8; }
@media (max-width: 480px) {
.ha-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; }
.ha-fab { right: 1rem; bottom: 1rem; }
}
+94
View File
@@ -558,3 +558,97 @@
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; } }
.herman-rss-list {
max-height: 68vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-right: 0.25rem;
}
.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; }
-10
View File
@@ -68,16 +68,6 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
}
.hm-exec-grid { grid-template-columns: 1fr !important; }
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
.vtabs-layout { grid-template-columns: 1fr !important; }
.vtabs-nav {
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
position: relative !important;
}
.vtabs-nav::before { display: none; }
.vtab-btn { flex: 0 0 auto; white-space: nowrap; }
.retail-workspace, .retail-workspace.with-filters {
grid-template-columns: 1fr !important;
}
+442
View File
@@ -0,0 +1,442 @@
/* Parse Intelligence Workbench */
.pi-workbench {
--pi-accent: #00e5ff;
--pi-gold: #ffd700;
--pi-purple: #a855f7;
--pi-bg: rgba(6, 10, 18, 0.92);
border-radius: 16px;
border: 1px solid rgba(0, 229, 255, 0.12);
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
}
.pi-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
}
.pi-header h2 {
margin: 0;
font-size: 1.15rem;
background: linear-gradient(90deg, #f1f5f9, #00e5ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.pi-kpi-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
@media (max-width: 900px) {
.pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
}
.pi-kpi-cell {
padding: 0.85rem 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.08);
transition: background 0.2s;
}
.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
.pi-kpi-cell:last-child { border-right: none; }
.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
.pi-body {
display: grid;
grid-template-columns: 260px 1fr 380px;
min-height: 520px;
}
@media (max-width: 1200px) {
.pi-body { grid-template-columns: 240px 1fr; }
.pi-detail { display: none; }
.pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
}
.pi-filters {
padding: 1rem;
border-right: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(0, 0, 0, 0.2);
overflow-y: auto;
max-height: 720px;
}
.pi-filters label {
display: block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin: 0.85rem 0 0.35rem;
font-weight: 700;
}
.pi-filters label:first-child { margin-top: 0; }
.pi-search {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(0, 229, 255, 0.25);
background: rgba(0, 0, 0, 0.35);
color: #f1f5f9;
font-size: 0.85rem;
}
.pi-search:focus {
outline: none;
border-color: rgba(0, 229, 255, 0.55);
box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
}
.pi-select, .pi-range {
width: 100%;
padding: 0.45rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.8);
color: #e2e8f0;
font-size: 0.82rem;
}
.pi-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #cbd5e1;
margin: 0.45rem 0;
cursor: pointer;
}
.pi-toggle-row input { accent-color: #00e5ff; }
.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
.pi-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(0, 229, 255, 0.12);
border: 1px solid rgba(0, 229, 255, 0.35);
color: #7dd3fc;
cursor: pointer;
}
.pi-filter-chip button {
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.9rem;
}
.pi-main {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.pi-trends-bar {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(168, 85, 247, 0.04);
}
.pi-trends-bar .lbl {
font-size: 0.68rem;
color: #94a3b8;
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pi-hype-chip {
font-size: 0.76rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(168, 85, 247, 0.35);
background: rgba(168, 85, 247, 0.1);
color: #e9d5ff;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
user-select: none;
}
.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
.pi-hype-chip.active {
border-color: #00e5ff;
background: rgba(0, 229, 255, 0.15);
color: #a5f3fc;
box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
}
.pi-hype-chip.cross {
border-color: rgba(255, 215, 0, 0.45);
background: rgba(255, 215, 0, 0.08);
color: #fde68a;
}
.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
.pi-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(0, 0, 0, 0.15);
}
.pi-toolbar .count {
font-size: 0.78rem;
color: #94a3b8;
margin-right: auto;
}
.pi-toolbar .count strong { color: #00e5ff; }
.pi-sort-btn {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.pi-sort-btn.active {
border-color: rgba(0, 229, 255, 0.45);
color: #00e5ff;
background: rgba(0, 229, 255, 0.08);
}
.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
.pi-view-toggle button {
font-size: 0.72rem;
padding: 0.3rem 0.65rem;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
}
.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
.pi-table-wrap {
flex: 1;
overflow: auto;
max-height: 420px;
}
.pi-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.pi-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
padding: 0.55rem 0.75rem;
background: rgba(10, 15, 25, 0.98);
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
color: #64748b;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.pi-table th:hover { color: #00e5ff; }
.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
.pi-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
vertical-align: middle;
color: #e2e8f0;
}
.pi-table tr { cursor: pointer; transition: background 0.12s; }
.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
.pi-table tbody tr.selected {
background: rgba(0, 229, 255, 0.1);
box-shadow: inset 3px 0 0 #00e5ff;
}
.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
.pi-badge {
display: inline-block;
font-size: 0.62rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-weight: 700;
}
.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
.pi-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.65rem;
padding: 0.75rem 1rem;
overflow-y: auto;
max-height: 420px;
}
.pi-card {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
padding: 0.85rem;
background: rgba(7, 11, 18, 0.8);
cursor: pointer;
transition: border-color 0.15s, transform 0.12s;
}
.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
.pi-detail {
display: flex;
flex-direction: column;
border-left: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(0, 0, 0, 0.25);
min-width: 0;
}
.pi-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: #475569;
font-size: 0.85rem;
}
.pi-detail-head {
padding: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
.pi-detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.pi-detail-tabs button {
flex: 1;
padding: 0.5rem;
border: none;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.pi-detail-tabs button.active {
color: #00e5ff;
border-bottom-color: #00e5ff;
background: rgba(0, 229, 255, 0.05);
}
.pi-detail-body {
flex: 1;
overflow-y: auto;
padding: 0.85rem 1rem;
max-height: 480px;
}
.pi-read-text {
white-space: pre-wrap;
font-size: 0.78rem;
line-height: 1.6;
color: #cbd5e1;
}
.pi-read-text mark {
background: rgba(255, 215, 0, 0.35);
color: #fef9c3;
padding: 0 0.15rem;
border-radius: 2px;
}
.pi-link-list { list-style: none; padding: 0; margin: 0; }
.pi-link-list li {
padding: 0.35rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
font-size: 0.78rem;
}
.pi-link-list a { color: #86efac; word-break: break-all; }
.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
.pi-heading-item {
font-size: 0.78rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
background: rgba(0, 229, 255, 0.06);
border-left: 3px solid rgba(0, 229, 255, 0.4);
color: #e2e8f0;
}
.pi-loading {
padding: 2rem;
text-align: center;
color: #64748b;
}
.pi-empty {
padding: 2.5rem 1rem;
text-align: center;
color: #64748b;
}
.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
+493
View File
@@ -0,0 +1,493 @@
/* Revenue Cockpit — Excel layout (licht) */
.rc-excel-page {
--rc-green: #92d050;
--rc-red: #ff0000;
--rc-orange: #ffc000;
--rc-yellow: #ffff00;
--rc-blue: #bdd7ee;
--rc-border: #b4b4b4;
--rc-text: #000;
background: #e8e8e8;
color: var(--rc-text);
margin: -1rem -1.25rem 0;
padding: 0.75rem 1rem 2rem;
min-height: calc(100vh - 4rem);
font-family: Calibri, "Segoe UI", Arial, sans-serif;
font-size: 11pt;
}
.persona-ceo .main-topnav:has(.rc-excel-page),
.main-topnav:has(.rc-excel-page) {
background: #e8e8e8;
}
.rc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
gap: 0.5rem;
}
.rc-toolbar-title {
font-weight: 700;
font-size: 0.85rem;
color: #333;
}
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
.rc-save-hint { font-size: 0.72rem; color: #666; }
.rc-saved { color: #217346; }
.rc-btn {
border: 1px solid #aaa;
background: #fff;
border-radius: 3px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
cursor: pointer;
color: #222;
}
.rc-btn:hover { background: #f3f3f3; }
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
.rc-btn-import { font-size: 0.72rem; }
.rc-sheet-wrap {
overflow: auto;
background: #fff;
border: 1px solid #888;
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
}
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
.rc-sheet {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
}
.rc-col-logo { width: 72px; }
.rc-col-deal { width: 16%; }
.rc-col-num { width: 10%; }
.rc-col-steps { width: auto; }
/* Alleen bewerk-modus (echte Excel) — niet lekken naar cockpit */
.rc-sheet .rc-row td {
border: 1px solid var(--rc-border);
vertical-align: top;
padding: 4px 6px;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
color: #000;
}
.rc-sheet .rc-row-goals td {
background: var(--rc-yellow);
font-weight: 600;
min-height: 48px;
}
.rc-sheet .rc-cell-tagline {
font-size: 14pt;
font-style: italic;
text-align: center;
}
.rc-sheet .rc-row-head td {
background: #fff;
font-weight: 700;
border-bottom: 2px solid #666;
}
.rc-sheet .rc-th { text-align: left; }
.rc-sheet .rc-num { text-align: right; }
.rc-sheet .rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
.rc-sheet .rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
.rc-sheet .rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
.rc-sheet .rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
.rc-sheet .rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
.rc-sheet .rc-style-white td:not(.rc-logo-cell) { background: #fff; }
.rc-sheet .rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
.rc-sheet .rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
.rc-sheet .rc-cell-edit:focus {
outline: 2px solid #217346;
outline-offset: -2px;
background: #fff !important;
color: #000 !important;
}
.rc-sheet .rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
.rc-sheet .rc-cell-deal { font-weight: 600; }
.rc-sheet .rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
.rc-sheet .rc-logo-cell {
background: #fff !important;
text-align: center;
vertical-align: middle;
padding: 6px 4px;
width: 72px;
}
.rc-logo-cucina .rc-logo-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 6.5pt;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.02em;
text-align: center;
}
.rc-logo-foodlinkk {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
}
.rc-fl-brand {
font-size: 11pt;
font-weight: 900;
color: #111;
letter-spacing: -0.02em;
}
.rc-fl-brand::first-letter { color: #f5c400; }
.rc-fl-sub {
font-size: 5.5pt;
font-weight: 700;
letter-spacing: 0.04em;
color: #333;
text-transform: uppercase;
}
/* Rij panel */
.rc-row-panel {
position: fixed;
bottom: 1rem;
right: 1rem;
width: min(420px, calc(100vw - 2rem));
background: #fff;
border: 1px solid #888;
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
border-radius: 6px;
z-index: 100;
font-size: 0.82rem;
}
.rc-row-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
.rc-inline select, .rc-inline input {
flex: 1;
border: 1px solid #bbb;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
}
.rc-style-select {
border: 1px solid #bbb;
font-size: 0.72rem;
padding: 0.15rem 0.35rem;
}
.rc-footnote {
margin: 0.75rem 0 0;
font-size: 0.72rem;
color: #666;
}
@media (max-width: 768px) {
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
}
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
.rc-page {
--rc-green: #4ade80;
--rc-red: #f87171;
--rc-orange: #fb923c;
--rc-yellow: #facc15;
--rc-blue: #60a5fa;
--rc-green-fill: #22c55e;
--rc-red-fill: #ef4444;
--rc-orange-fill: #f59e0b;
--rc-yellow-fill: #eab308;
--rc-blue-fill: #3b82f6;
color: #e2e8f0;
}
.rc-page.rc-mode-sheet { background: transparent; }
.rc-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.rc-header h1 { margin: 0; font-size: 1.25rem; }
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.rc-view-toggle {
display: flex;
border: 1px solid rgba(148,163,184,0.2);
border-radius: 8px;
overflow: hidden;
}
.rc-toggle-btn {
border: none;
background: transparent;
color: #94a3b8;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
cursor: pointer;
}
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
.rc-summary-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
padding: 0.55rem 0.85rem;
background: rgba(15,20,25,0.6);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 10px;
}
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
.rc-sum-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
.rc-sum-item strong { font-size: 1.05rem; color: #4ade80; font-variant-numeric: tabular-nums; }
.rc-filter-inline {
flex: 1 1 140px;
min-width: 120px;
max-width: 220px;
padding: 0.35rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(148,163,184,0.18);
background: #0a0e12;
color: #e2e8f0;
font-size: 0.78rem;
margin-left: auto;
}
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
.rc-dot-btn {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(148,163,184,0.3);
background: transparent;
cursor: pointer;
padding: 0;
}
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
.rc-dot-green { background: var(--rc-green); }
.rc-dot-red { background: var(--rc-red); }
.rc-dot-orange { background: var(--rc-orange); }
.rc-dot-yellow { background: var(--rc-yellow); }
.rc-dot-blue { background: var(--rc-blue); }
.rc-dot-white { background: #e2e8f0; }
.rc-visual-wrap {
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(148,163,184,0.12);
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
background: #0a0e12;
}
.rc-visual-table {
width: 100%;
min-width: 680px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: #111820;
font-family: inherit;
font-size: 0.9rem;
color: #f1f5f9;
}
.rc-col-deal { width: 14%; }
.rc-col-chart-num { width: 12%; }
.rc-col-donut { width: 72px; }
.rc-col-steps { width: auto; }
.rc-visual-goals td {
background: rgba(234, 179, 8, 0.2);
color: #fef3c7;
font-weight: 600;
font-size: 0.85rem;
padding: 8px 12px;
border-bottom: 1px solid rgba(234, 179, 8, 0.25);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-vtagline { font-style: italic; text-align: center; color: #fde047; font-size: 0.95rem; }
.rc-visual-head td {
background: #1a2332;
color: #cbd5e1;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 12px;
border-bottom: 2px solid rgba(148,163,184,0.2);
position: sticky;
top: 0;
z-index: 2;
}
.rc-visual-row { cursor: pointer; }
.rc-visual-row:hover td { background: #1e2a3a !important; }
.rc-visual-row.rc-row-open td { background: #1a3050 !important; }
.rc-visual-row td,
.rc-visual-goals td,
.rc-visual-head td,
.rc-vcell {
border-bottom: 1px solid rgba(148,163,184,0.1);
padding: 10px 12px;
vertical-align: middle;
line-height: 1.45;
background: #151c28;
color: #f1f5f9;
}
/* Status = gekleurde linkerrand, neutrale donkere rij */
.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
.rc-vdeal { font-weight: 700; }
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rc-brand-tag {
display: inline-block;
margin-top: 2px;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
padding: 1px 5px;
border-radius: 3px;
background: rgba(148,163,184,0.15);
}
.rc-brand-tag-fl { color: #fbbf24; background: rgba(251,191,36,0.12); }
.rc-vnum { text-align: right; }
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.92rem; font-weight: 700; color: #4ade80; }
.rc-mini-bar {
height: 5px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
margin-top: 5px;
overflow: hidden;
}
.rc-mini-bar-fill { height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.95; }
.rc-mini-bar-year { background: #38bdf8 !important; }
.rc-vdonut { text-align: center; }
.rc-donut {
width: 40px; height: 40px;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.rc-donut-label { font-size: 0.62rem; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
.rc-donut-empty { color: #64748b; font-size: 0.85rem; }
.rc-vsteps {
font-size: 0.84rem;
color: #cbd5e1;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 6px; }
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
.rc-expand-cell { padding: 0 !important; }
.rc-expand-inner {
display: flex;
gap: 1rem;
padding: 0.85rem 1rem;
background: rgba(10,14,18,0.98);
color: #e2e8f0;
border-top: 1px solid rgba(56,189,248,0.15);
}
.rc-expand-chart { flex: 0 0 140px; }
.rc-bar-chart {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.5rem;
height: 72px;
margin-bottom: 0.5rem;
}
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
.rc-bar-stack {
width: 28px;
min-height: 2px;
background: var(--rc-green-fill);
border-radius: 3px 3px 0 0;
transition: height 0.2s;
}
.rc-bar-year { background: #38bdf8; }
.rc-expand-stats { font-size: 0.75rem; }
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
.rc-expand-stats span { color: #64748b; }
.rc-expand-stats strong { color: #34d399; }
.rc-expand-body { flex: 1; min-width: 0; }
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
.rc-expand-body p { margin: 0; font-size: 0.9rem; line-height: 1.55; white-space: pre-wrap; color: #e2e8f0; }
.rc-expand-task { margin-top: 0.65rem; }
.rc-expand-task .rc-inline select,
.rc-expand-task .rc-inline input {
border: 1px solid rgba(148,163,184,0.25);
background: #0f1419;
color: #e2e8f0;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
border-radius: 4px;
}
.rc-page .rc-excel-page {
margin: 0;
padding: 0;
min-height: auto;
background: #e8e8e8;
color: #000;
border-radius: 8px;
overflow: hidden;
}
@media (max-width: 768px) {
.rc-summary-strip { gap: 0.5rem; }
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
.rc-expand-inner { flex-direction: column; }
}
+422
View File
@@ -0,0 +1,422 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+262
View File
@@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
<header class="rc-header">
<div>
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
<p class="rc-subtitle" x-show="meta.file_mtime">
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
· <span x-text="fmtDate(meta.file_mtime)"></span>
</p>
</div>
<div class="rc-header-actions">
<div class="rc-view-toggle">
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
</div>
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
</div>
</header>
<!-- Cockpit: Excel-layout met charts -->
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
<div class="rc-summary-strip">
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / mnd</span>
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Marge / jr</span>
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
</div>
<div class="rc-sum-item">
<span class="rc-sum-label">Deals</span>
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
</div>
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
<div class="rc-style-dots">
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
<template x-for="s in styleFilters" :key="'d-' + s.id">
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
</template>
</div>
</div>
<div class="rc-visual-wrap">
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-deal" />
<col class="rc-col-chart-num" />
<col class="rc-col-chart-num" />
<col class="rc-col-donut" />
<col class="rc-col-steps" />
</colgroup>
<!-- Excel rij 1: goals -->
<tr class="rc-visual-goals">
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
</tr>
<tr class="rc-visual-head">
<td class="rc-vcell">Deal</td>
<td class="rc-vcell rc-num">Marge mnd</td>
<td class="rc-vcell rc-num">Marge jr</td>
<td class="rc-vcell rc-num">Aandeel</td>
<td class="rc-vcell">Next steps</td>
</tr>
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
<tr class="rc-visual-row"
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<td class="rc-vcell rc-vdeal">
<span class="rc-deal-name" x-text="row.name"></span>
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_month">
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
</td>
<td class="rc-vcell rc-vnum">
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
<div class="rc-mini-bar" x-show="row.margin_year">
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
</div>
</td>
<td class="rc-vcell rc-vdonut">
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
</div>
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
</td>
<td class="rc-vcell rc-vsteps">
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
</td>
</tr>
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
<td class="rc-expand-cell" colspan="5">
<div class="rc-expand-inner">
<div class="rc-expand-chart">
<div class="rc-bar-chart">
<div class="rc-bar-col">
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
<span>Mnd</span>
</div>
<div class="rc-bar-col">
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
<span>Jr</span>
</div>
</div>
<div class="rc-expand-stats">
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
</div>
</div>
<div class="rc-expand-body">
<h4>Next steps</h4>
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
<div class="rc-expand-task" x-show="row.db_id">
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</table>
</div>
</div>
<!-- Bewerken: originele Excel -->
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
<div class="rc-toolbar">
<span class="rc-toolbar-title">Bewerken · DB</span>
<div class="rc-toolbar-actions">
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
</div>
</div>
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
<table class="rc-sheet" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-logo" />
<col class="rc-col-deal" />
<col class="rc-col-num" />
<col class="rc-col-num" />
<col class="rc-col-steps" />
</colgroup>
<tr class="rc-row rc-row-goals">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.vision_text || ''"
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.horizon_text || ''"
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit" contenteditable="true"
x-init="$el.textContent = goals.mid_text || ''"
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
x-init="$el.textContent = goals.tagline || ''"
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
</tr>
<tr class="rc-row rc-row-head">
<td class="rc-logo-cell"></td>
<td class="rc-cell rc-th">Deal</td>
<td class="rc-cell rc-th rc-num">Marge Month</td>
<td class="rc-cell rc-th rc-num">Marge Year</td>
<td class="rc-cell rc-th">Next steps</td>
</tr>
<template x-for="row in sheetRows" :key="row.id || row.source_row">
<tr class="rc-row rc-data-row"
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
@click="selectRow(row)">
<template x-if="row.logoCell === 'cucina'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
</td>
</template>
<template x-if="row.logoCell === 'foodlinkk'">
<td class="rc-logo-cell" :rowspan="row.logoSpan">
<div class="rc-logo rc-logo-foodlinkk">
<span class="rc-fl-brand">Foodlinkk</span>
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
</div>
</td>
</template>
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.name || ''"
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_month)"
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
x-init="$el.textContent = fmtNum(row.margin_year)"
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
x-init="$el.textContent = row.next_steps || ''"
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
</tr>
</template>
</table>
</div>
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
<div class="rc-row-panel-head">
<strong x-text="selectedRow?.name"></strong>
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
<option value="green">Groen (live)</option>
<option value="red">Rood (inactief)</option>
<option value="orange">Oranje</option>
<option value="yellow">Geel (sectie)</option>
<option value="blue">Blauw</option>
<option value="white">Wit</option>
</select>
</div>
<div class="rc-row-panel-body">
<label>Agent taak
<div class="rc-inline">
<select x-model="taskForm.agent_name">
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
</select>
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
</div>
</label>
</div>
</div>
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=5"></script>
{% endblock %}
+13
View File
@@ -194,3 +194,16 @@
.nav-pill.active { animation: none; }
.nav-pill:hover { transform: none; }
}
/* Sidebar: app-sidebar-neo.css only — no topnav glow/pulse */
.app-sidebar .nav-pill,
.app-sidebar .nav-pill:hover,
.app-sidebar .nav-pill.active {
animation: none !important;
box-shadow: none !important;
text-shadow: none !important;
}
.app-sidebar .nav-pill::before,
.app-sidebar .nav-pill::after {
display: none !important;
}
+232 -71
View File
@@ -1,117 +1,278 @@
/* Vertical tab navigation with animated active indicator */
/* Vertical tabs — clean module nav + page tabs */
.vtabs-layout {
display: grid;
grid-template-columns: 220px 1fr;
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
gap: 1rem;
align-items: start;
min-height: 60vh;
width: 100%;
min-height: 40vh;
--vt-color: #0ea5e9;
--vt-panel-bg: rgba(10, 14, 20, 0.85);
}
.vtabs-layout > .vtabs-nav {
flex: 0 0 220px;
width: 220px;
max-width: 220px;
}
.vtabs-layout > .vtabs-content {
flex: 1 1 0;
min-width: 0;
}
@media (max-width: 639px) {
.vtabs-layout { flex-direction: column !important; }
.vtabs-layout > .vtabs-nav { flex: 1 1 auto; width: 100%; max-width: none; }
}
.vtabs-nav {
position: sticky;
top: 1rem;
top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
border-radius: 14px;
background: rgba(10, 14, 20, 0.85);
gap: 0.1rem;
padding: 0.65rem;
border-radius: 12px;
background: rgba(10, 14, 20, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
overflow: hidden;
}
.vtabs-nav::before {
content: '';
position: absolute;
left: 0;
top: var(--vt-indicator-top, 0);
width: 3px;
height: var(--vt-indicator-h, 40px);
background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
border-radius: 0 4px 4px 0;
transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
box-shadow: 0 0 12px var(--vt-color, #00e5ff);
height: var(--vt-indicator-h, 36px);
background: var(--vt-color);
border-radius: 0 3px 3px 0;
transition: top 0.25s ease, height 0.2s ease;
}
.vtabs-nav-header {
padding: 0 0.35rem 0.5rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.vtabs-nav-header strong {
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #94a3b8;
}
.vtab-btn {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
gap: 0.55rem;
width: 100%;
padding: 0.65rem 0.85rem;
border: none;
border-radius: 10px;
padding: 0.55rem 0.6rem;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
font-size: 0.8rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: color 0.2s, background 0.25s, transform 0.2s;
text-decoration: none;
--tab-accent: var(--vt-color, #0ea5e9);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.vtab-btn:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.04);
transform: translateX(4px);
}
.vtab-btn.active {
color: #fff;
background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent);
text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent);
}
.vtab-icon { font-size: 1.1rem; flex-shrink: 0; }
.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; }
@keyframes vtabFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
.vtab-btn::before { display: none; }
.vtab-icon,
.ei-vtab-icon-wrap {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.1);
}
/* App sidebar layout */
.app-shell {
display: grid;
grid-template-columns: 200px 1fr;
min-height: 100vh;
.vtab-btn:hover {
background: rgba(30, 41, 59, 0.5);
color: #e2e8f0;
transform: none;
}
.app-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: 1rem 0.65rem;
background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95));
border-right: 1px solid rgba(148, 163, 184, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
.vtab-btn.active {
background: rgba(14, 165, 233, 0.08);
border-color: rgba(14, 165, 233, 0.2);
color: #f1f5f9;
text-shadow: none;
box-shadow: none;
}
.app-sidebar-brand {
padding: 0.5rem 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
margin-bottom: 0.5rem;
.vtab-btn.active .vtab-icon,
.vtab-btn.active .ei-vtab-icon-wrap {
border-color: var(--tab-accent);
background: rgba(14, 165, 233, 0.12);
box-shadow: none;
transform: none;
}
.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; }
.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; }
.app-sidebar-group { margin-bottom: 0.75rem; }
.app-sidebar-label {
font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em;
color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem;
.vtab-badge,
.ei-vtab-badge {
margin-left: auto;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.15);
color: #cbd5e1;
border: none;
min-width: 1.25rem;
text-align: center;
}
.app-sidebar .nav-pill {
display: flex; width: 100%; justify-content: flex-start;
margin-bottom: 0.2rem; border-radius: 10px;
.vtab-btn.active .ei-vtab-badge,
.vtab-btn.active .vtab-badge {
background: rgba(14, 165, 233, 0.2);
color: #7dd3fc;
animation: none;
}
.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; }
/* Rich tabs (Wereldexport) */
.vtab-btn.vtab-btn-rich {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
}
.ei-vtab-text { min-width: 0; }
.ei-vtab-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: inherit;
line-height: 1.2;
}
.ei-vtab-sub {
display: block;
font-size: 0.62rem;
color: #64748b;
margin-top: 0.1rem;
}
.ei-vtab-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
}
.ei-vtab-key {
font-size: 0.55rem;
color: #475569;
font-family: ui-monospace, monospace;
}
.vtabs-content { min-width: 0; }
.vt-tab-panel,
.ei-tab-panel {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: var(--vt-panel-bg);
overflow: hidden;
}
.vt-tab-panel-head,
.ei-tab-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(15, 23, 42, 0.4);
}
.vt-tab-panel-head h2,
.ei-tab-panel-head h2 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: #e2e8f0;
}
.vt-tab-panel-head p,
.ei-tab-panel-head p {
margin: 0.1rem 0 0;
font-size: 0.72rem;
color: #64748b;
}
.vt-tab-panel-body,
.ei-tab-panel-body { padding: 1rem; }
/* Retail side-nav */
.side-nav {
position: sticky;
top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.65rem;
border-radius: 12px;
background: rgba(10, 14, 20, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.side-nav-btn {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.55rem 0.6rem;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 0.8rem;
font-weight: 500;
text-align: left;
cursor: pointer;
text-decoration: none;
}
.side-nav-btn:hover { background: rgba(30, 41, 59, 0.5); color: #e2e8f0; }
.side-nav-btn.active {
background: rgba(0, 102, 204, 0.1);
border-color: rgba(0, 102, 204, 0.3);
color: #fff;
}
.app-main { min-width: 0; }
.app-topbar {
display: flex; justify-content: space-between; align-items: center;
padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(8, 12, 18, 0.6);
}
.app-clock {
font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #94a3b8;
}
.app-clock strong { color: #e2e8f0; }
.main-topnav { padding: 1rem 1.25rem; }
/* Mobile layout → see mobile.css */
@media (max-width: 639px) {
.vtabs-nav {
position: relative;
top: 0;
flex-direction: row;
overflow-x: auto;
flex-wrap: nowrap;
padding: 0.45rem;
}
.vtabs-nav::before { display: none; }
.vtabs-nav-header { display: none; }
.vtab-btn { flex: 0 0 auto; min-width: 64px; }
.vtab-btn.vtab-btn-rich .ei-vtab-text,
.vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
}
+179
View File
@@ -0,0 +1,179 @@
.voice-live-page { --vl-accent: #a78bfa; max-width: 1400px; }
.voice-live-grid {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 1rem;
min-height: 520px;
}
@media (max-width: 1100px) {
.voice-live-grid { grid-template-columns: 1fr; }
}
.voice-panel {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(10, 14, 20, 0.9);
padding: 1rem;
display: flex;
flex-direction: column;
min-height: 0;
}
.voice-mic-zone { align-items: center; text-align: center; }
.voice-mic-btn {
width: 120px;
height: 120px;
border-radius: 50%;
border: 3px solid rgba(167, 139, 250, 0.45);
background: radial-gradient(circle at 30% 30%, rgba(167, 139, 250, 0.25), rgba(15, 20, 25, 0.95));
color: #f8fafc;
font-size: 2.5rem;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
margin: 0.5rem auto 1rem;
}
.voice-mic-btn:hover { border-color: rgba(167, 139, 250, 0.8); transform: scale(1.03); }
.voice-mic-btn.is-listening {
border-color: #f87171;
box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.2);
animation: vl-pulse 1.2s ease-in-out infinite;
}
.voice-mic-btn.is-busy {
border-color: #38bdf8;
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.15);
}
.voice-mic-btn.is-handsfree {
border-color: #34d399;
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
animation: vl-pulse-hf 2s ease-in-out infinite;
}
@keyframes vl-pulse-hf {
0%, 100% { box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12); }
50% { box-shadow: 0 0 0 12px rgba(52, 211, 153, 0.22); }
}
@keyframes vl-pulse {
0%, 100% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0.15); }
50% { box-shadow: 0 0 0 14px rgba(248, 113, 113, 0.28); }
}
.voice-status {
font-size: 0.82rem;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 0.35rem;
}
.voice-status-sub { font-size: 0.72rem; color: #64748b; margin-bottom: 0.75rem; }
.voice-live-preview {
width: 100%;
min-height: 3.5rem;
padding: 0.65rem 0.75rem;
border-radius: 10px;
background: rgba(15, 20, 25, 0.85);
border: 1px dashed rgba(167, 139, 250, 0.35);
font-size: 0.85rem;
color: #cbd5e1;
text-align: left;
line-height: 1.4;
}
.voice-live-preview.is-interim { color: #94a3b8; font-style: italic; }
.voice-live-preview strong { color: #a78bfa; font-size: 0.68rem; text-transform: uppercase; display: block; margin-bottom: 0.25rem; }
.voice-toggles { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 1rem; text-align: left; }
.voice-toggles label { font-size: 0.78rem; color: #94a3b8; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.voice-chat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.voice-chat-head h2 { margin: 0; font-size: 0.95rem; color: #f8fafc; }
.voice-chat-log {
flex: 1;
overflow-y: auto;
max-height: 480px;
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-right: 0.25rem;
}
.voice-bubble {
max-width: 92%;
padding: 0.65rem 0.85rem;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.45;
}
.voice-bubble-user {
align-self: flex-end;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.3);
}
.voice-bubble-agent {
align-self: flex-start;
background: rgba(167, 139, 250, 0.12);
border: 1px solid rgba(167, 139, 250, 0.28);
}
.voice-bubble-meta { font-size: 0.68rem; color: #64748b; margin-bottom: 0.25rem; }
.voice-bubble-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd;
margin-bottom: 0.35rem;
}
.voice-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.voice-side-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin: 0 0 0.5rem; }
.voice-pipeline { list-style: none; margin: 0 0 1rem; padding: 0; max-height: 220px; overflow-y: auto; }
.voice-pipeline li {
display: grid;
grid-template-columns: 72px 1fr;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.voice-pipeline .vl-phase {
font-size: 0.62rem;
text-transform: uppercase;
color: #64748b;
font-weight: 700;
}
.voice-pipeline .vl-label { color: #e2e8f0; font-weight: 600; }
.voice-pipeline .vl-detail { grid-column: 2; color: #94a3b8; font-size: 0.72rem; }
.voice-pipeline li.is-running .vl-label { color: #38bdf8; }
.voice-pipeline li.is-error .vl-label { color: #f87171; }
.voice-feed { max-height: 200px; overflow-y: auto; font-size: 0.72rem; }
.voice-feed-item { padding: 0.35rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); color: #94a3b8; }
.voice-feed-item strong { color: #c4b5fd; }
.voice-feed-item .vl-ts { color: #64748b; margin-right: 0.35rem; }
.voice-manual-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.voice-manual-row .form-input { flex: 1; font-size: 0.82rem; }
.voice-confirm-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 12px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.35);
}
.voice-confirm-text { font-size: 0.82rem; color: #fde68a; }
.voice-confirm-text strong { display: block; color: #fbbf24; font-size: 0.72rem; text-transform: uppercase; margin-bottom: 0.2rem; }
.voice-confirm-actions { display: flex; gap: 0.5rem; }
.voice-results-modal { z-index: 1400; }
.voice-results-card { width: min(720px, 96vw); max-height: 85vh; display: flex; flex-direction: column; }
.voice-results-table-wrap { overflow: auto; max-height: 50vh; margin: 0.5rem 0 1rem; border: 1px solid rgba(148,163,184,0.12); border-radius: 8px; }
.voice-results-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.voice-results-table th { text-align: left; padding: 0.5rem 0.65rem; background: rgba(15,20,25,0.9); color: #64748b; font-size: 0.65rem; text-transform: uppercase; position: sticky; top: 0; }
.voice-results-table td { padding: 0.45rem 0.65rem; border-top: 1px solid rgba(148,163,184,0.08); color: #cbd5e1; }
.voice-results-table a { color: #38bdf8; text-decoration: none; }
.voice-results-table a:hover { text-decoration: underline; }
+12 -1
View File
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overview",
"revenue_cockpit": "Revenue Cockpit",
"cto_home": "CTO Overview",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Clients",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "New client",
"tab_kanban": "Kanban",
"tab_list": "List",
"tab_stores": "Supermarkets"
"tab_stores": "Supermarkets",
"tab_360": "360° Client"
},
"products": {
"title": "Product catalog",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Cucina · Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0e14",
"theme_color": "#0a0e14",
"lang": "nl",
"icons": [
{
"src": "/static/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+12 -1
View File
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
"persona_ceo": "CEO · Business",
"persona_cto": "CTO · Tech",
"ceo_home": "CEO Overzicht",
"revenue_cockpit": "Revenue Cockpit",
"cto_home": "CTO Overzicht",
"business": "Business",
"platform": "Platform",
"devops": "DevOps",
"tools_api": "Tools API",
"grafana": "Grafana",
"crm": "CRM",
"clients": "Klanten",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "Nieuwe klant",
"tab_kanban": "Kanban",
"tab_list": "Lijst",
"tab_stores": "Supermarkten"
"tab_stores": "Supermarkten",
"tab_360": "360° Klant"
},
"products": {
"title": "Productcatalogus",
+1
View File
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
};
function cfg(key, fallbackColor) {
+756 -83
View File
@@ -1,9 +1,149 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
const TIER_ROWS = [4, 5, 7];
const MIN_NODE_GAP = 148;
const VIEW_W = 1200;
const VIEW_H = 860;
function make(tag, attrs = {}) {
/** Korte rol-context per agent (NL) */
const AGENT_HINTS = {
marketing: { short: 'Campagnes', domain: 'Groei' },
retail: { short: 'Supermarkten', domain: 'Groei' },
bizdev: { short: 'Deals', domain: 'Groei' },
research: { short: 'Marktintel', domain: 'Groei' },
browser: { short: 'Web research', domain: 'Groei' },
webbuilder: { short: 'Websites', domain: 'Product' },
product: { short: 'Assortiment', domain: 'Product' },
packaging: { short: 'Verpakking', domain: 'Product' },
design: { short: 'Visuals', domain: 'Product' },
halal: { short: 'Compliance', domain: 'Product' },
sourcing: { short: 'Inkoop', domain: 'Product' },
finance: { short: 'Cijfers', domain: 'Operatie' },
hr: { short: 'Team', domain: 'Operatie' },
knowledge: { short: 'Kennisbank', domain: 'Operatie' },
email: { short: 'Inbox', domain: 'Operatie' },
sysops: { short: 'Infra', domain: 'Operatie' },
};
const CLUSTERS = {
growth: {
label: 'Groei',
icon: '📈',
color: 'rgba(0, 229, 255, 0.14)',
stroke: 'rgba(0, 229, 255, 0.35)',
agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
},
product: {
label: 'Product',
icon: '📦',
color: 'rgba(168, 85, 247, 0.12)',
stroke: 'rgba(168, 85, 247, 0.35)',
agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
},
ops: {
label: 'Operatie',
icon: '⚙️',
color: 'rgba(34, 197, 94, 0.1)',
stroke: 'rgba(34, 197, 94, 0.32)',
agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
},
};
/** @type {Record<string, object>} */
const COMM_MODES = {
pyramid: {
id: 'pyramid',
label: 'Piramide',
subtitle: 'Herman als hub',
icon: '◆',
desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.42,
},
mesh: {
id: 'mesh',
label: 'Vol netwerk',
subtitle: 'Alle partners',
icon: '⬡',
desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.35,
edgeBend: 0.38,
},
org: {
id: 'org',
label: 'Organigram',
subtitle: 'Pure hiërarchie',
icon: '▤',
desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: false,
showStaticPeers: false,
activeOnly: false,
edgeBend: 0.12,
straightExec: true,
},
pulse: {
id: 'pulse',
label: 'Live stream',
subtitle: 'Alleen actief',
icon: '◎',
desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
layout: 'pyramid',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: false,
activeOnly: true,
edgeBend: 0.45,
},
clusters: {
id: 'clusters',
label: 'Domeinen',
subtitle: 'Per afdeling',
icon: '◉',
desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
layout: 'clusters',
showExecutive: true,
showReport: true,
showDelegate: true,
showLivePeers: true,
showStaticPeers: true,
activeOnly: false,
staticPeerOpacity: 0.2,
edgeBend: 0.5,
intraClusterOnly: false,
},
};
const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
let cachedData = null;
let pickerBound = false;
let onModeChangeCb = null;
let lastMountOpts = {};
function make(tag, attrs) {
attrs = attrs || {};
const el = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
Object.entries(attrs).forEach(function (pair) {
el.setAttribute(pair[0], String(pair[1]));
});
return el;
}
@@ -11,9 +151,234 @@
while (el.firstChild) el.removeChild(el.firstChild);
}
function linePath(x1, y1, x2, y2) {
const midY = y1 + (y2 - y1) * 0.45;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
function curvePath(x1, y1, x2, y2, bend) {
if (bend != null && bend < 0.18) {
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
}
const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
}
function clusterOf(key) {
const k = (key || '').toLowerCase();
return (
Object.keys(CLUSTERS).find(function (cid) {
return CLUSTERS[cid].agents.indexOf(k) >= 0;
}) || null
);
}
function spreadTier(nodes, width, minGap) {
if (nodes.length <= 1) return;
nodes.sort(function (a, b) {
return a._x - b._x;
});
for (let i = 1; i < nodes.length; i++) {
const gap = nodes[i]._x - nodes[i - 1]._x;
if (gap < minGap) {
const shift = minGap - gap;
for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
}
}
const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
const offset = width / 2 - tierCx;
nodes.forEach(function (n) {
n._x += offset;
});
}
function deoverlapLayout(souls, width) {
const tiers = {};
souls.forEach(function (n) {
const y = n._y || 0;
if (!tiers[y]) tiers[y] = [];
tiers[y].push(n);
});
Object.keys(tiers).forEach(function (y) {
spreadTier(tiers[y], width, MIN_NODE_GAP);
});
}
function agentSubtitle(node) {
const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
const task = (node.current_task || node.last_event_title || '').trim();
if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
if (hint) return hint.short;
return (node.role_title || '').split('·')[0].trim().slice(0, 24);
}
function agentStatus(node) {
if (node.is_active) return 'live';
const h = node.health || 'idle';
if (h === 'warn') return 'warn';
if (h === 'healthy') return 'ready';
return 'idle';
}
function addRotateAnim(group, dur) {
const anim = document.createElementNS(NS, 'animateTransform');
anim.setAttribute('attributeName', 'transform');
anim.setAttribute('type', 'rotate');
anim.setAttribute('from', '0 0 0');
anim.setAttribute('to', '360 0 0');
anim.setAttribute('dur', dur || '12s');
anim.setAttribute('repeatCount', 'indefinite');
group.appendChild(anim);
}
function layoutPyramid(souls, width, positions) {
const cx = width / 2;
const hermanY = positions.hermanY;
const execY = positions.execY;
const tierYs = positions.tierYs;
const minGap = MIN_NODE_GAP;
let idx = 0;
TIER_ROWS.forEach(function (count, tierIdx) {
const y = tierYs[tierIdx] || 580;
const slice = souls.slice(idx, idx + count);
idx += count;
const n = slice.length;
if (!n) return;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
slice.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._tier = tierIdx;
node._cluster = clusterOf(node.agent_key);
});
});
while (idx < souls.length) {
const y = 660;
const rest = souls.slice(idx);
const n = rest.length;
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
const startX = cx - span / 2;
rest.forEach(function (node, i) {
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
node._y = y;
node._cluster = clusterOf(node.agent_key);
});
idx = souls.length;
}
deoverlapLayout(souls, width);
return { cx: cx, hermanY: hermanY, execY: execY };
}
function layoutClusters(souls, width, height) {
const cx = width / 2;
const hermanY = height * 0.42;
const execY = 58;
const clusterCenters = {
growth: { x: cx - 280, y: height * 0.72, r: 118 },
product: { x: cx, y: height * 0.78, r: 118 },
ops: { x: cx + 280, y: height * 0.72, r: 118 },
};
souls.forEach(function (node) {
const key = (node.agent_key || '').toLowerCase();
const cid = clusterOf(key) || 'product';
const center = clusterCenters[cid];
const members = CLUSTERS[cid].agents;
const idx = members.indexOf(key);
const n = members.length;
const angleStart = -Math.PI * 0.72;
const angleEnd = -Math.PI * 0.28;
const angle =
n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
const dist = center.r * 0.72;
node._x = center.x + Math.cos(angle) * dist;
node._y = center.y + Math.sin(angle) * dist * 0.55;
node._cluster = cid;
node._clusterCx = center.x;
node._clusterCy = center.y;
});
return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
}
function isSvgVisible(svg) {
if (!svg) return false;
const r = svg.getBoundingClientRect();
return r.width > 8 && r.height > 8;
}
function showMeshLoading(svg, text) {
clear(svg);
const g = make('g', { class: 'mesh-loading' });
const t = make('text', {
x: 600,
y: 360,
'text-anchor': 'middle',
fill: '#94a3b8',
'font-size': 14,
});
t.textContent = text || 'Netwerk laden…';
g.appendChild(t);
svg.appendChild(g);
}
function showMeshError(svg, text) {
clear(svg);
const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
t.textContent = text || 'Mesh kon niet geladen worden';
svg.appendChild(t);
}
function whenSvgVisible(svg, fn) {
if (!svg) return;
if (isSvgVisible(svg)) {
fn();
return;
}
let tries = 0;
const tick = function () {
tries += 1;
if (isSvgVisible(svg)) {
fn();
return;
}
if (tries < 60) {
requestAnimationFrame(tick);
} else {
fn();
}
};
requestAnimationFrame(tick);
}
function watchSvgResize(svg, renderFn) {
if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
if (!wrap) return;
let hadSize = isSvgVisible(svg);
svg._meshResizeObs = new ResizeObserver(function () {
const vis = isSvgVisible(svg);
if (vis && !hadSize) {
hadSize = true;
renderFn();
} else if (vis) {
hadSize = true;
}
});
svg._meshResizeObs.observe(wrap);
}
function renderMesh(svg, opts) {
if (!svg || !cachedData) return;
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
commMode: (opts && opts.commMode) || getSavedMode(),
});
const doDraw = function () {
try {
drawMesh(svg, cachedData, drawOpts);
} catch (e) {
console.error('drawMesh failed', e);
showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
}
};
doDraw();
watchSvgResize(svg, doDraw);
}
async function loadMeshData() {
@@ -22,67 +387,209 @@
return r.json();
}
function drawMesh(svg, data) {
async function ensureMeshData() {
if (cachedData) return cachedData;
cachedData = await loadMeshData();
return cachedData;
}
let pendingRender = false;
function scheduleRender(opts) {
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return;
if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
if (pendingRender) return;
pendingRender = true;
whenSvgVisible(svg, function () {
pendingRender = false;
renderMesh(svg, opts);
});
}
function nodePos(nodesByKey, key) {
const n = nodesByKey[key];
if (n && n._x != null) return { x: n._x, y: n._y };
return null;
}
function resolveMode(opts) {
const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] || COMM_MODES.pyramid;
}
function drawMesh(svg, data, opts) {
opts = opts || {};
const mode = resolveMode(opts);
const onNodeClick = opts.onNodeClick;
clear(svg);
svg.setAttribute('data-mesh-mode', mode.id);
const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1100;
const height = vb && vb.height ? vb.height : 640;
const width = vb && vb.width ? vb.width : VIEW_W;
const height = vb && vb.height ? vb.height : VIEW_H;
if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
}
const cx = width / 2;
const hermanY = 280;
const execY = 75;
const agentY = 520;
const positions = {
execY: 72,
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
tierYs: [400, 540, 690],
};
const souls = (data.nodes || []).filter((n) => {
const souls = (data.nodes || []).filter(function (n) {
const key = (n.agent_key || '').toLowerCase();
return key && key !== 'herman';
});
const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
const layoutMeta =
mode.layout === 'clusters'
? layoutClusters(souls, width, height)
: layoutPyramid(souls, width, positions);
const agentCount = Math.max(1, souls.length);
const pad = 70;
const span = width - pad * 2;
souls.forEach((node, i) => {
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
if (agentCount === 1) node._x = cx;
node._y = agentY;
const cx = layoutMeta.cx;
const hermanY = layoutMeta.hermanY;
const execY = layoutMeta.execY;
const nodesByKey = {};
souls.forEach(function (n) {
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
});
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
const edgesLayer = make('g');
const nodesLayer = make('g');
const bgLayer = make('g', { class: 'mesh-bg' });
const clusterLayer = make('g', { class: 'mesh-clusters' });
const edgesLayer = make('g', { class: 'mesh-edges' });
const nodesLayer = make('g', { class: 'mesh-nodes' });
svg.appendChild(bgLayer);
if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
function addEdge(x1, y1, x2, y2, pulse) {
const d = linePath(x1, y1, x2, y2);
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
if (mode.layout === 'pyramid') {
positions.tierYs.forEach(function (y, i) {
bgLayer.appendChild(
make('rect', {
x: 40,
y: y - 42,
width: width - 80,
height: 84,
rx: 12,
class: 'mesh-tier-band mesh-tier-' + i,
})
);
});
}
if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
Object.keys(CLUSTERS).forEach(function (cid) {
const c = CLUSTERS[cid];
const center = layoutMeta.clusterCenters[cid];
if (!center) return;
const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
g.appendChild(
make('ellipse', {
cx: center.x,
cy: center.y,
rx: center.r + 28,
ry: center.r * 0.62,
fill: c.color,
stroke: c.stroke,
'stroke-width': 1.2,
})
);
const lbl = make('text', {
x: center.x,
y: center.y - center.r * 0.35,
class: 'mesh-cluster-label',
'text-anchor': 'middle',
});
lbl.textContent = c.icon + ' ' + c.label;
g.appendChild(lbl);
clusterLayer.appendChild(g);
});
}
function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
const attrs = { d: d, class: 'mesh-edge ' + cls };
if (extraAttrs) Object.assign(attrs, extraAttrs);
edgesLayer.appendChild(make('path', attrs));
if (pulse) {
const p = make('path', { d, class: 'mesh-pulse' });
p.style.animationDuration = pulse + 's';
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
edgesLayer.appendChild(p);
}
}
souls.forEach((node) => {
const weight = edgesBySource[node.agent_key]?.weight || 1;
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
if (mode.showExecutive) {
const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
}
(data.peer_edges || []).forEach(function (e) {
const live = e.type === 'live';
if (live && !mode.showLivePeers) return;
if (!live && !mode.showStaticPeers) return;
if (mode.activeOnly && !live) return;
const a = nodePos(nodesByKey, e.source);
const b = nodePos(nodesByKey, e.target);
if (!a || !b) return;
const srcNode = nodesByKey[e.source];
const tgtNode = nodesByKey[e.target];
const sameCluster =
srcNode &&
tgtNode &&
srcNode._cluster &&
srcNode._cluster === tgtNode._cluster;
if (mode.intraClusterOnly && !sameCluster) return;
const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
const pulse = live ? 2.2 : false;
const extra = {};
if (!live && mode.staticPeerOpacity != null) {
extra['stroke-opacity'] = mode.staticPeerOpacity;
}
if (sameCluster && mode.layout === 'clusters') {
extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
}
addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
});
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
if (mode.showReport) {
(data.report_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const a = nodePos(nodesByKey, e.source);
if (!a) return;
addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
});
}
if (mode.showDelegate) {
(data.delegate_edges || []).forEach(function (e) {
if (mode.activeOnly && !e.active) return;
const b = nodePos(nodesByKey, e.target);
if (!b) return;
addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
});
}
function drawExec(node, cls) {
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
g.lastChild.textContent = node.label;
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
t.textContent = node.label;
g.appendChild(t);
const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
sub.textContent = node.sub;
g.appendChild(sub);
nodesLayer.appendChild(g);
@@ -91,56 +598,222 @@
drawExec(ceo, 'mesh-ceo');
drawExec(cto, 'mesh-cto');
const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
crown.textContent = '👑';
herman.appendChild(crown);
const hLabel = make('text', { x: cx, y: hermanY + 18 });
hLabel.textContent = 'Herman · Co-CEO';
herman.appendChild(hLabel);
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
hSub.textContent = 'Takenverdeler';
herman.appendChild(hSub);
nodesLayer.appendChild(herman);
const hermanActive =
(data.delegate_edges || []).some(function (e) {
return e.active;
}) ||
(data.report_edges || []).some(function (e) {
return e.active;
});
souls.forEach((node) => {
const hermanG = make('g', {
class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''),
transform: 'translate(' + cx + ',' + hermanY + ')',
});
if (hermanActive) {
const ringG = make('g', { class: 'mesh-herman-ring' });
ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 }));
addRotateAnim(ringG, '14s');
hermanG.appendChild(ringG);
}
hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 }));
const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' });
crown.textContent = '👑';
hermanG.appendChild(crown);
const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' });
hLabel.textContent = 'Herman';
hermanG.appendChild(hLabel);
const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' });
hSub.textContent = 'Co-CEO · takenverdeler';
hermanG.appendChild(hSub);
const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' });
const hermanNode = (data.nodes || []).find(function (n) {
return (n.agent_key || '').toLowerCase() === 'herman';
});
hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents';
hermanG.appendChild(hTask);
nodesLayer.appendChild(hermanG);
souls.forEach(function (node, idx) {
const key = (node.agent_key || '').toLowerCase();
const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName;
const live = !!node.is_active;
const status = agentStatus(node);
const hint = AGENT_HINTS[key];
const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : '';
const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1;
const group = make('g', {
class:
'mesh-node mesh-agent mesh-status-' +
status +
' ' +
(node.health || 'idle') +
(live ? ' mesh-agent-live' : '') +
clusterCls,
transform: 'translate(' + node._x + ',' + node._y + ')',
});
if (live) {
const pulseG = make('g', { class: 'mesh-pulse-wrap' });
pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' }));
group.appendChild(pulseG);
}
group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' }));
const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' });
emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●';
group.appendChild(emoji);
const displayName = (node.display_name || key || '?').split(' ')[0];
const nameY = labelAbove ? -30 : 36;
const ctxY = labelAbove ? -16 : 52;
const domY = labelAbove ? -2 : 66;
const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' });
t.textContent = displayName;
group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
group.appendChild(role);
const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
status.textContent = (node.health || 'idle').toUpperCase();
group.appendChild(status);
group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) {
Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
}
const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
ctx.textContent = agentSubtitle(node);
group.appendChild(ctx);
if (hint && mode.layout !== 'clusters') {
const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
dom.textContent = hint.domain;
group.appendChild(dom);
}
const tip =
(node.role_title || '') +
(node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
(node.current_task ? '\nNu: ' + node.current_task : '');
group.setAttribute('title', tip.slice(0, 200));
group.addEventListener('click', function () {
if (onNodeClick) onNodeClick(node);
});
nodesLayer.appendChild(group);
});
}
async function mount(targetId) {
const svg = document.getElementById(targetId);
if (!svg) return;
try {
const data = await loadMeshData();
drawMesh(svg, data);
} catch (e) {
clear(svg);
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
msg.textContent = 'Mesh kon niet geladen worden';
svg.appendChild(msg);
function updatePickerUI(modeId) {
document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
const active = btn.getAttribute('data-mesh-mode') === modeId;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
const wrap = document.querySelector('.mesh-wrap');
if (wrap) {
wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
wrap.setAttribute('data-mode', modeId);
}
const badge = document.getElementById('mesh-mode-badge');
if (badge && COMM_MODES[modeId]) {
badge.textContent = COMM_MODES[modeId].label;
}
}
window.AgentsMesh = { mount };
function pickMode(modeId, opts, silent) {
const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
if (!silent) saveMode(mode);
updatePickerUI(mode);
if (onModeChangeCb) onModeChangeCb(mode);
const svg = document.getElementById('agents-mesh-canvas');
if (!svg) return mode;
showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
ensureMeshData()
.then(function () {
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
})
.catch(function (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
});
return mode;
}
function bindModePicker(opts) {
lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
if (pickerBound) return;
pickerBound = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-mesh-mode]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = btn.getAttribute('data-mesh-mode');
pickMode(id);
});
}
function setModeChangeCallback(fn) {
onModeChangeCb = fn;
}
async function mount(targetId, opts) {
opts = opts || {};
lastMountOpts = opts;
const svg = document.getElementById(targetId);
if (!svg) return null;
bindModePicker(opts);
const modeObj = resolveMode(opts);
updatePickerUI(modeObj.id);
showMeshLoading(svg, 'Netwerk laden…');
try {
await ensureMeshData();
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
whenSvgVisible(svg, function () {
renderMesh(svg, drawOpts);
});
return cachedData;
} catch (e) {
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
return null;
}
}
async function refresh(targetId, opts) {
cachedData = null;
return mount(targetId, opts);
}
function redraw(targetId, opts) {
const svg = document.getElementById(targetId);
if (!svg || !cachedData) return;
whenSvgVisible(svg, function () {
renderMesh(svg, opts || {});
});
}
function saveMode(modeId) {
if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
}
function getSavedMode() {
const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
return COMM_MODES[id] ? id : 'pyramid';
}
window.AgentsMesh = {
mount,
refresh,
redraw,
pickMode,
bindModePicker,
setModeChangeCallback,
loadMeshData,
drawMesh,
COMM_MODES,
CLUSTERS,
saveMode,
getSavedMode,
};
function bootPicker() {
bindModePicker({});
const saved = getSavedMode();
updatePickerUI(saved);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootPicker);
} else {
bootPicker();
}
if (window.location.pathname.indexOf('/agents') >= 0) {
ensureMeshData().catch(function () {});
}
})();
+244
View File
@@ -0,0 +1,244 @@
(function () {
const buffers = {};
let liveEs = null;
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function nowIso() {
return new Date().toISOString();
}
function lineClass(type) {
if (!type) return 'line-action';
if (type === 'command') return 'line-command';
if (type === 'output') return 'line-output';
if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
if (type === 'error') return 'line-error';
if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
if (String(type).indexOf('browse') >= 0) return 'line-action';
return 'line-action';
}
function formatLine(entry) {
const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
const msg = entry.message || entry.title || '';
const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
return (
'<div class="term-line ' +
lineClass(entry.type) +
'"><span class="term-ts">' +
esc(t) +
'</span><span class="term-msg">' +
esc(msg + detail) +
'</span></div>'
);
}
function render(key) {
const els = document.querySelectorAll('[data-term-key="' + key + '"]');
if (!els.length) return;
const lines = buffers[key] || [];
const html =
!lines.length
? '<div class="term-line term-idle"><span class="term-msg">Typ <code>help</code> voor commando\'s · Enter om uit te voeren</span></div>'
: lines.slice(-80).map(formatLine).join('');
els.forEach(function (el) {
el.innerHTML = html;
el.scrollTop = el.scrollHeight;
const win = el.closest('.agent-term-window');
if (win && lines.length) win.classList.add('is-live');
});
}
function pushLine(key, entry, skipDup) {
if (!key || !entry) return;
key = String(key).toLowerCase();
if (!buffers[key]) buffers[key] = [];
if (skipDup && entry._id) {
const seen = buffers[key].some(function (l) {
return l._id === entry._id;
});
if (seen) return;
}
if (!entry.at) entry.at = nowIso();
buffers[key].push(entry);
if (buffers[key].length > 120) buffers[key].shift();
render(key);
}
function setBusy(key, busy) {
document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
input.disabled = !!busy;
input.classList.toggle('is-busy', !!busy);
});
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
const win = body.closest('.agent-term-window');
if (win) win.classList.toggle('is-running', !!busy);
});
}
async function runCommand(key, cmd) {
key = String(key || '').toLowerCase();
cmd = (cmd || '').trim();
if (!cmd) return;
if (cmd.toLowerCase() === 'clear') {
buffers[key] = [];
render(key);
return;
}
pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
setBusy(key, true);
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd }),
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
const err =
(typeof data.detail === 'string' && data.detail) ||
(Array.isArray(data.detail) && data.detail[0]?.msg) ||
'Commando mislukt';
pushLine(key, { type: 'error', message: err, at: nowIso() });
return;
}
(data.lines || []).forEach(function (line) {
pushLine(key, {
type: line.type || 'output',
message: line.message || '',
detail: line.detail || '',
at: nowIso(),
});
});
} catch (e) {
pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
} finally {
setBusy(key, false);
}
}
function bindInputs() {
document.querySelectorAll('[data-term-input]').forEach(function (input) {
if (input._termBound) return;
input._termBound = true;
const key = input.getAttribute('data-term-input');
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const val = input.value;
input.value = '';
runCommand(key, val);
}
});
input.addEventListener('click', function (e) {
e.stopPropagation();
});
});
}
async function loadHistory(key) {
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
if (!r.ok) return;
const data = await r.json();
buffers[key] = (data.items || []).map(function (ev) {
const meta = ev.metadata || {};
let type = 'action';
const et = ev.event_type || '';
if (et.indexOf('handoff') >= 0) type = 'handoff_out';
if (meta.target_agent) type = 'handoff_out';
if (meta.source_agent) type = 'handoff_in';
if (et.indexOf('monitor') >= 0) type = 'monitor';
if (et.indexOf('browse') >= 0) type = 'browse';
if (et === 'terminal_in') type = 'command';
if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
return {
_id: ev.id,
type: type,
message: ev.title || ev.event_type,
detail: ev.body,
at: ev.created_at,
};
});
render(key);
} catch (e) {}
}
function handleLivePayload(d) {
if (!d || d.type === 'connected' || d.type === 'error') return;
const key = (d.agent || '').toLowerCase();
if (!key) return;
pushLine(
key,
{
_id: d.id,
type: d.type,
message: d.message,
detail: d.detail,
at: d.at,
},
true
);
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
const win = el.closest('.agent-term-window');
if (win) win.classList.add('is-live');
});
}
function startLiveStream() {
if (liveEs || typeof EventSource === 'undefined') return;
liveEs = new EventSource('/api/agents/live/stream');
liveEs.onmessage = function (ev) {
try {
handleLivePayload(JSON.parse(ev.data));
} catch (e) {}
};
liveEs.onerror = function () {
/* EventSource auto-reconnects */
};
}
function stopLiveStream() {
if (liveEs) {
try {
liveEs.close();
} catch (e) {}
liveEs = null;
}
}
async function mountAll() {
const nodes = document.querySelectorAll('[data-term-key]');
const keys = [];
nodes.forEach(function (n) {
const k = n.getAttribute('data-term-key');
if (k && keys.indexOf(k) < 0) keys.push(k);
});
await Promise.all(keys.map(loadHistory));
bindInputs();
startLiveStream();
}
function stopAll() {
stopLiveStream();
}
window.AgentsTerminals = {
mountAll,
stopAll,
pushLine,
render,
runCommand,
bindInputs,
startLiveStream,
stopLiveStream,
};
})();
File diff suppressed because one or more lines are too long
+12 -31
View File
@@ -174,19 +174,15 @@ window.BriefingCharts = (function () {
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
var items = stats.food_market_highlights || stats.rss_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen highlights — <a href="/marketing">RSS ophalen</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = (r.category || 'markt').toUpperCase();
return '<div class="hm-highlight-item"><span class="hm-cat-badge">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
@@ -332,17 +328,7 @@ window.BriefingCharts = (function () {
function renderRetail(container, stats) {
if (!container) return;
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var when = (r.published_at || '').substring(0, 10);
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
}).join('');
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
@@ -369,19 +355,15 @@ window.BriefingCharts = (function () {
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5);
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (trending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
trending.forEach(function (r) {
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
});
html += '</ul>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
@@ -423,7 +405,6 @@ window.BriefingCharts = (function () {
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
+3 -4
View File
@@ -3,7 +3,6 @@
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
rss: { title: 'RSS live feed', icon: '📰' },
retail: { title: 'Retail operatie', icon: '🏪' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
@@ -17,9 +16,9 @@
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/?tab=rss' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/?tab=rss' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing?tab=bookmarks' },
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
+66
View File
@@ -0,0 +1,66 @@
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
(function () {
var DEFAULT = 'overview';
function radioId(tab) {
return 'doctab-' + tab;
}
function getActiveTab() {
var checked = document.querySelector('.doc-tab-input:checked');
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
}
function show(tab) {
if (!tab) tab = DEFAULT;
var radio = document.getElementById(radioId(tab));
if (radio) radio.checked = true;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
window.__docActiveTab = tab;
}
function syncAlpine(tab) {
var hub = document.querySelector('.doc-hub');
if (!hub || !window.Alpine) return;
try {
var d = Alpine.$data(hub);
if (!d) return;
if (d.activeTab !== tab) d.activeTab = tab;
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
} catch (e) {
console.warn('doc-tabs alpine', e);
}
}
function onTabChange() {
var tab = getActiveTab();
window.__docActiveTab = tab;
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
});
syncAlpine(tab);
}
function bind() {
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
if (radio.dataset.bound) return;
radio.dataset.bound = '1';
radio.addEventListener('change', onTabChange);
});
}
window.DocTabs = { show: show, getActive: getActiveTab };
bind();
document.addEventListener('DOMContentLoaded', function () {
bind();
show(window.__docActiveTab || getActiveTab());
});
document.addEventListener('alpine:initialized', function () {
bind();
show(window.__docActiveTab || getActiveTab());
syncAlpine(getActiveTab());
});
})();
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
/**
* Export Intel map — Leaflet + clustering + rich popups
*/
window.ExportIntelMap = (function () {
var map = null;
var clusterLayer = null;
var onEntityClick = null;
var halalMode = false;
var markersById = {};
var HALAL_COLORS = {
hot: '#22c55e',
warm: '#eab308',
mild: '#f97316',
low: '#64748b',
};
var TYPE_CFG = {
distributor: { color: '#f59e0b', icon: '📦', label: 'Distributeur' },
wholesaler: { color: '#eab308', icon: '🏪', label: 'Groothandel' },
importer: { color: '#f97316', icon: '🚢', label: 'Importeur' },
logistics: { color: '#38bdf8', icon: '🚚', label: 'Logistiek' },
contract_caterer:{ color: '#a78bfa', icon: '🏢', label: 'Cateraar' },
restaurant: { color: '#34d399', icon: '🍽️', label: 'Restaurant' },
doner_shoarma: { color: '#4ade80', icon: '🥙', label: 'Döner / shoarma' },
butcher: { color: '#f87171', icon: '🥩', label: 'Slager' },
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
};
function cfg(type) {
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function row(label, val, link) {
if (!val) return '';
var inner = link
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
: escapeHtml(val);
return (
'<div class="ei-popup-row">' +
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
'<span class="ei-popup-v">' + inner + '</span>' +
'</div>'
);
}
function halalColor(p) {
var tier = p.halal_tier || 'low';
return HALAL_COLORS[tier] || HALAL_COLORS.low;
}
function popupHtml(p) {
var c = cfg(p.entity_type);
var id = p.id;
var web = p.website;
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
var halalRow = '';
if (p.halal_score != null) {
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
}
var contactBits = [];
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
var contactRow = contactBits.length
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
: '';
return (
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
'<div class="ei-popup-top">' +
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
'</div>' +
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
'<div class="ei-popup-grid">' +
halalRow +
contactRow +
row('Stad', p.city) +
row('Adres', p.address_line) +
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
row('Website', p.website, web) +
row('Volume', p.volume_band) +
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
row('Pipeline', p.pipeline_stage) +
'</div>' +
'<div class="ei-popup-actions">' +
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
'</div>' +
'</div>'
);
}
function pinIcon(type, p) {
var c = cfg(type);
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
return L.divIcon({
className: 'ei-pin-wrap',
html:
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
'<span class="ei-pin-ring"></span>' +
'<span class="ei-pin-icon">' + c.icon + '</span>' +
'</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -16],
});
}
function openEntityId(rawId) {
var id = Number(rawId);
if (!Number.isFinite(id)) return;
if (onEntityClick) {
onEntityClick(id);
return;
}
try {
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
} catch (e) { /* empty */ }
}
function wirePopup(container) {
if (!container) return;
var popupRoot = container.closest('.leaflet-popup') || container;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(popupRoot);
L.DomEvent.disableScrollPropagation(popupRoot);
}
var btn = container.querySelector('.ei-popup-btn');
var id = btn && btn.getAttribute('data-entity-id');
if (btn && id) {
var onBtn = function (e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
openEntityId(id);
};
if (window.L && L.DomEvent) {
L.DomEvent.on(btn, 'click', onBtn);
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
} else {
btn.addEventListener('click', onBtn);
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
}
}
container.addEventListener('click', function (e) {
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
var card = e.target.closest('[data-entity-id]');
if (card) openEntityId(card.getAttribute('data-entity-id'));
});
}
function init(containerId) {
var el = document.getElementById(containerId);
if (!el || !window.L) return null;
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OSM &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
if (window.L.markerClusterGroup) {
clusterLayer = L.markerClusterGroup({
maxClusterRadius: 42,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: function (cluster) {
var n = cluster.getChildCount();
var size = n < 10 ? 'sm' : n < 50 ? 'md' : 'lg';
return L.divIcon({
html: '<div class="ei-cluster ei-cluster-' + size + '"><span>' + n + '</span></div>',
className: 'ei-cluster-wrap',
iconSize: L.point(44, 44),
});
},
});
map.addLayer(clusterLayer);
} else {
clusterLayer = L.layerGroup();
map.addLayer(clusterLayer);
}
map.on('popupopen', function (e) {
var root = e.popup && e.popup.getElement();
if (!root) return;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(root);
L.DomEvent.disableScrollPropagation(root);
}
wirePopup(root.querySelector('.leaflet-popup-content') || root);
});
return map;
}
function setFeatures(geojson) {
if (!map || !clusterLayer) return;
clusterLayer.clearLayers();
markersById = {};
var features = (geojson && geojson.features) || [];
var markers = [];
features.forEach(function (f) {
var coords = f.geometry && f.geometry.coordinates;
if (!coords) return;
var p = f.properties || {};
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) });
marker._eiId = p.id;
if (p.id != null) markersById[p.id] = marker;
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' });
marker.on('dblclick', function () {
if (onEntityClick && p.id) onEntityClick(p.id);
});
markers.push(marker);
});
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
if (features.length === 1) {
var c = features[0].geometry.coordinates;
map.setView([c[1], c[0]], 10);
} else if (markers.length > 1 && clusterLayer.getBounds) {
try {
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
} catch (e) { /* empty */ }
}
}
function setHalalMode(on) {
halalMode = !!on;
}
function setOnEntityClick(fn) {
onEntityClick = fn;
}
function flyTo(lat, lon, zoom) {
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
}
function highlightEntity(id) {
var marker = markersById[id];
if (!marker || !map) return;
var latlng = marker.getLatLng();
map.setView(latlng, Math.max(map.getZoom(), 14));
marker.openPopup();
}
function closePopup() {
if (map) map.closePopup();
}
function destroy() {
if (map) {
map.remove();
map = null;
clusterLayer = null;
}
}
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
})();
+596
View File
@@ -0,0 +1,596 @@
function exportIntelApp() {
return {
tab: 'map',
tabIndicatorTop: 0,
tabIndicatorH: 40,
busy: false,
syncMsg: '',
stats: {},
regions: [],
territories: [],
country: '',
region: '',
entities: [],
contacts: [],
tenders: [],
catererPresence: [],
catererBrands: [],
govSources: [],
selected: null,
drawerOpen: false,
q: '',
entityTypeFilter: '',
mapTypesSelected: [],
mapHalalMode: false,
mapHalalMin: 55,
halalTopMarkets: [],
halalTopEntities: [],
listFocusId: null,
listFocusDetail: null,
listFocusLoading: false,
drawerLoading: false,
filterHasEmail: '',
filterFavoritesOnly: false,
filterCrmStatus: '',
selectedIds: [],
crmPipeline: [],
crmModalOpen: false,
crmCreateDeals: true,
crmPushBusy: false,
resultCount: 0,
_searchTimer: null,
tabs: [
{ id: 'map', icon: '🗺️', label: 'Kaart', color: '#0ea5e9', key: '1' },
{ id: 'distributors', icon: '📦', label: 'Distributeurs', color: '#fbbf24', key: '2' },
{ id: 'customers', icon: '🍽️', label: 'Eindklanten', color: '#34d399', key: '3' },
{ id: 'caterers', icon: '🏢', label: 'Cateraars', color: '#a78bfa', key: '4' },
{ id: 'contacts', icon: '📇', label: 'Contacten', color: '#38bdf8', key: '5' },
{ id: 'tenders', icon: '📋', label: 'Tenders', color: '#c084fc', key: '6' },
{ id: 'gov', icon: '🏛️', label: 'Overheid', color: '#94a3b8', key: '7' },
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
],
mapTypeChips: [
{ v: 'distributor', l: 'Distributeur', icon: '📦' },
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
{ v: 'importer', l: 'Importeur', icon: '🚢' },
{ v: 'logistics', l: 'Logistiek', icon: '🚚' },
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' },
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
{ v: 'butcher', l: 'Slager', icon: '🥩' },
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
{ v: 'foodservice', l: 'Foodservice', icon: '🍴' },
],
async init() {
var params = new URLSearchParams(location.search);
var t = params.get('tab');
if (t) this.tab = t;
var self = this;
document.addEventListener('export-intel:open-entity', function (ev) {
if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id);
});
await this.loadMeta();
await this.refresh();
this.$nextTick(() => this.updateTabIndicator());
window.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select')) return;
var n = parseInt(e.key, 10);
if (n >= 1 && n <= 8) {
var btn = this.$refs.vtabsNav?.querySelector('[data-tab="' + this.tabs[n - 1].id + '"]');
this.setTab(this.tabs[n - 1].id, btn);
}
});
},
async loadMeta() {
try {
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
} catch (e) {
console.error(e);
}
},
async refresh() {
this.busy = true;
try {
var cp = this.country ? '?country=' + encodeURIComponent(this.country) : '';
var rp = this.region ? (cp ? '&' : '?') + 'region=' + encodeURIComponent(this.region) : '';
this.stats = await fetch('/api/export-intel/stats' + cp + rp).then((r) => r.json());
await this.loadTabData();
if (this.tab === 'map') this.loadMap();
} catch (e) {
console.error(e);
}
this.busy = false;
},
filterQs() {
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
if (this.filterHasEmail === 'no') parts.push('has_email=false');
if (this.filterFavoritesOnly) parts.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
return parts.length ? '&' + parts.join('&') : '';
},
onSearchInput() {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.applyMapFilters(), 350);
},
clearFilters() {
this.q = '';
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
this.filterFavoritesOnly = false;
this.filterCrmStatus = '';
this.selectedIds = [];
this.applyMapFilters();
},
showEntityTypeFilter() {
return ['distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
},
showMapTypeFilter() {
return this.tab === 'map';
},
isMapTypeActive(v) {
return this.mapTypesSelected.indexOf(v) >= 0;
},
toggleMapType(v) {
var i = this.mapTypesSelected.indexOf(v);
if (i >= 0) this.mapTypesSelected.splice(i, 1);
else this.mapTypesSelected.push(v);
this.entityTypeFilter = '';
this.applyMapFilters();
},
clearMapTypes() {
this.mapTypesSelected = [];
this.applyMapFilters();
},
mapTypesParam() {
if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter };
if (this.mapTypesSelected.length) {
return { entity_types: this.mapTypesSelected.join(',') };
}
return {};
},
applyMapFilters() {
if (this.tab !== 'map') {
this.loadTabData();
return;
}
this.loadTabData().then(() => this.loadMap());
},
showEmailFilter() {
return this.tab === 'contacts';
},
entityTypeOptions() {
if (this.tab === 'distributors') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'importer', l: 'Importeur' },
{ v: 'logistics', l: 'Logistiek' },
];
}
if (this.tab === 'customers') {
return [
{ v: '', l: 'Alle types' },
{ v: 'restaurant', l: 'Restaurant' },
{ v: 'doner_shoarma', l: 'Döner / shoarma' },
{ v: 'butcher', l: 'Slager' },
{ v: 'foodservice', l: 'Foodservice' },
];
}
if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
}
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
];
},
async loadTabData() {
var base = '/api/export-intel';
var fq = this.filterQs();
if (this.tab === 'map') {
var mapQs = this.filterQs();
var mt = this.mapTypesParam();
if (mt.entity_type) mapQs += '&entity_type=' + encodeURIComponent(mt.entity_type);
else if (mt.entity_types) mapQs += '&entity_types=' + encodeURIComponent(mt.entity_types);
var rm = await fetch(base + '/entities?limit=200' + mapQs).then((x) => x.json());
this.entities = rm.items || [];
this.resultCount = rm.total != null ? rm.total : this.entities.length;
} else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics';
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
this.entities = r.items || [];
this.resultCount = r.total != null ? r.total : this.entities.length;
} else if (this.tab === 'customers') {
var types2 = 'restaurant,doner_shoarma,butcher,foodservice';
var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json());
this.entities = r2.items || [];
this.resultCount = r2.total != null ? r2.total : this.entities.length;
} else if (this.tab === 'caterers') {
var et3 = this.entityTypeFilter || 'contract_caterer';
var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
this.entities = r3.items || [];
this.resultCount = r3.total != null ? r3.total : this.entities.length;
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
} else if (this.tab === 'contacts') {
var r4 = await fetch(base + '/contacts?limit=500' + fq).then((x) => x.json());
this.contacts = r4.items || [];
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
} else if (this.tab === 'tenders') {
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
var items = tenders.items || [];
if (this.q && this.q.trim()) {
var ql = this.q.trim().toLowerCase();
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
}
this.tenders = items;
this.resultCount = items.length;
} else if (this.tab === 'gov') {
var gov = await fetch(base + '/gov-sources' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
if (this.q && this.q.trim()) {
var ql2 = this.q.trim().toLowerCase();
gov = gov.filter((g) => (g.name || '').toLowerCase().includes(ql2) || (g.category || '').toLowerCase().includes(ql2));
}
this.govSources = gov;
this.resultCount = gov.length;
} else if (this.tab === 'pipeline') {
var pipeQs = this.filterQs();
var pipe = await fetch(base + '/crm/pipeline?limit=200' + pipeQs).then((x) => x.json());
this.crmPipeline = pipe.items || [];
this.resultCount = this.crmPipeline.length;
}
},
async loadMap() {
this.clearListFocus();
var url = '/api/export-intel/map/bundle';
var qs = [];
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
if (this.region) qs.push('region=' + encodeURIComponent(this.region));
var mt = this.mapTypesParam();
if (mt.entity_type) qs.push('entity_type=' + encodeURIComponent(mt.entity_type));
else if (mt.entity_types) qs.push('entity_types=' + encodeURIComponent(mt.entity_types));
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
if (this.mapHalalMode) qs.push('halal_min=' + encodeURIComponent(this.mapHalalMin));
if (this.filterFavoritesOnly) qs.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
if (qs.length) url += '?' + qs.join('&');
var bundle = await fetch(url).then((r) => r.json());
this.halalTopMarkets = (bundle.meta && bundle.meta.top_markets) || [];
this.halalTopEntities = (bundle.meta && bundle.meta.top_halal_entities) || [];
if (window.ExportIntelMap) {
if (!document.getElementById('ei-map')) return;
var self = this;
window.ExportIntelMap.init('ei-map');
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] });
if (this.country) {
var t = this.territories.find((x) => x.country_iso2 === this.country);
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
}
}
},
setTab(id, el) {
this.tab = id;
this.entityTypeFilter = '';
this.mapTypesSelected = [];
this.mapHalalMode = false;
this.filterHasEmail = '';
var u = new URL(location.href);
u.searchParams.set('tab', id);
history.replaceState(null, '', u.pathname + u.search);
this.loadTabData();
if (id === 'map') this.$nextTick(() => this.loadMap());
this.$nextTick(() => this.updateTabIndicator(el));
},
updateTabIndicator(el) {
var nav = this.$refs.vtabsNav;
if (!nav) return;
var btn = el || nav.querySelector('.vtab-btn.active');
if (!btn) return;
var navRect = nav.getBoundingClientRect();
var btnRect = btn.getBoundingClientRect();
this.tabIndicatorTop = btnRect.top - navRect.top + nav.scrollTop;
this.tabIndicatorH = btnRect.height;
},
async onCountryChange() {
await this.refresh();
},
contactPreview(e) {
if (!e) return '—';
if (e.email || e.primary_email) return e.email || e.primary_email;
if (e.phone || e.primary_phone) return e.phone || e.primary_phone;
if (e.website) return e.website.replace(/^https?:\/\//, '').slice(0, 28);
if (e.has_contact) return 'Contact in registry';
return 'Geen contact';
},
async fetchEntityDetail(id) {
var r = await fetch('/api/export-intel/entities/' + id);
if (!r.ok) {
var err = await r.json().catch(function () { return {}; });
throw new Error((err && err.error) || ('HTTP ' + r.status));
}
var data = await r.json();
if (data && data.error) throw new Error(data.error);
return data;
},
async selectListEntity(id) {
if (!id) return;
this.listFocusId = id;
this.listFocusLoading = true;
this.listFocusDetail = null;
try {
var detail = await this.fetchEntityDetail(id);
this.listFocusDetail = detail;
if (window.ExportIntelMap && detail.lat && detail.lon) {
window.ExportIntelMap.flyTo(detail.lat, detail.lon, 14);
window.ExportIntelMap.highlightEntity(detail.id);
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.listFocusId = null;
}
this.listFocusLoading = false;
},
clearListFocus() {
this.listFocusId = null;
this.listFocusDetail = null;
},
async openEntity(id) {
if (!id) return;
this.drawerLoading = true;
this.drawerOpen = true;
try {
if (this.listFocusDetail && this.listFocusDetail.id === id) {
this.selected = this.listFocusDetail;
} else {
this.selected = await this.fetchEntityDetail(id);
this.listFocusId = id;
this.listFocusDetail = this.selected;
}
if (window.ExportIntelMap && this.selected.lat && this.selected.lon) {
window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14);
window.ExportIntelMap.highlightEntity(this.selected.id);
}
if (window.ExportIntelMap && window.ExportIntelMap.closePopup) {
window.ExportIntelMap.closePopup();
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.drawerOpen = false;
this.selected = null;
}
this.drawerLoading = false;
},
closeDrawer() {
this.drawerOpen = false;
this.selected = null;
this.drawerLoading = false;
},
async syncKind(kind) {
if (kind === 'world') {
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return;
}
if (kind === 'region' && !this.region) {
this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
return;
}
this.busy = true;
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
try {
var body = {
country_iso2: this.country || null,
region_code: kind === 'region' ? this.region : (this.region || null),
max_priority: 2,
};
if (kind === 'world') body = { max_priority: 2 };
var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
if (r.error) this.syncMsg = 'Fout: ' + r.error;
else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst';
else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid';
else this.syncMsg = 'Sync voltooid';
await this.refresh();
} catch (e) {
this.syncMsg = 'Sync mislukt: ' + e.message;
}
this.busy = false;
},
tabCount(id) {
if (id === 'distributors') return this.stats.distributors || 0;
if (id === 'caterers') return this.stats.caterers || 0;
if (id === 'contacts') return this.stats.contacts || 0;
if (id === 'tenders') return this.stats.tenders_open || 0;
if (id === 'pipeline') return this.stats.crm_linked || 0;
if (id === 'map') return this.stats.entities || 0;
return 0;
},
tabSub(id) {
var n = this.tabCount(id);
if (id === 'map') return n + ' op kaart';
if (id === 'gov') return 'Bronnen per land';
if (id === 'pipeline') return 'CRM koppeling';
if (n > 0) return n + ' records';
return '';
},
contactsExportUrl() {
var u = '/api/export-intel/contacts/export.csv?';
var parts = [];
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
return u + parts.join('&');
},
typeLabel(t) {
return (t || '').replace(/_/g, ' ');
},
territoryName(iso2) {
if (!iso2) return '';
var t = this.territories.find(function (x) { return x.country_iso2 === iso2; });
return t ? t.name_nl : iso2;
},
isSelected(id) {
return this.selectedIds.indexOf(id) >= 0;
},
toggleSelect(id, ev) {
if (ev) ev.stopPropagation();
var i = this.selectedIds.indexOf(id);
if (i >= 0) this.selectedIds.splice(i, 1);
else this.selectedIds.push(id);
},
toggleSelectAllVisible() {
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id));
if (allSelected) {
list.forEach((e) => {
var i = this.selectedIds.indexOf(e.id);
if (i >= 0) this.selectedIds.splice(i, 1);
});
} else {
list.forEach((e) => {
if (!this.isSelected(e.id)) this.selectedIds.push(e.id);
});
}
},
clearSelection() {
this.selectedIds = [];
},
async setFavorites(ids, favorite) {
if (!ids.length) return;
this.busy = true;
try {
await fetch('/api/export-intel/entities/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_ids: ids, favorite: favorite }),
});
this.syncMsg = favorite
? ids.length + ' favoriet' + (ids.length > 1 ? 'en' : '') + ' opgeslagen'
: 'Favoriet verwijderd';
await this.refresh();
} catch (e) {
this.syncMsg = 'Favoriet mislukt: ' + e.message;
}
this.busy = false;
},
async toggleFavorite(id, current, ev) {
if (ev) ev.stopPropagation();
await this.setFavorites([id], !current);
},
async favoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), true);
},
async unfavoriteSelection() {
await this.setFavorites(this.selectedIds.slice(), false);
},
openCrmModal() {
if (!this.selectedIds.length) {
this.syncMsg = 'Selecteer eerst één of meer rijen (checkbox)';
return;
}
this.crmModalOpen = true;
},
async pushToCrm(ids) {
var entityIds = (ids && ids.length) ? ids : this.selectedIds.slice();
if (!entityIds.length) return;
this.crmPushBusy = true;
this.syncMsg = 'CRM import gestart…';
try {
var r = await fetch('/api/export-intel/crm/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_ids: entityIds,
create_deals: this.crmCreateDeals,
}),
}).then((x) => x.json());
if (r.error) throw new Error(r.error);
var created = r.created || 0;
var linked = r.linked_existing || 0;
this.syncMsg = 'CRM: ' + created + ' nieuw, ' + linked + ' bestaand gekoppeld';
this.crmModalOpen = false;
this.selectedIds = [];
await this.refresh();
if (this.listFocusDetail && entityIds.indexOf(this.listFocusDetail.id) >= 0) {
this.listFocusDetail = await this.fetchEntityDetail(this.listFocusDetail.id);
}
if (this.tab === 'pipeline') await this.loadTabData();
} catch (e) {
this.syncMsg = 'CRM import mislukt: ' + e.message;
}
this.crmPushBusy = false;
},
async pushEntityToCrm(id) {
this.crmCreateDeals = true;
await this.pushToCrm([id]);
},
onFilterCollectionChange() {
this.applyMapFilters();
},
};
}
+373
View File
@@ -0,0 +1,373 @@
/**
* Herman Assistant — browser widget + gedeelde chat/voice logica
*/
window.HermanAssistant = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
butcher: 'Slager', doner: 'Döner',
};
return m[t] || t || '—';
}
function newSessionId(prefix) {
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
}
function baseState(opts) {
opts = opts || {};
return {
channel: opts.channel || 'browser',
sessionPrefix: opts.sessionPrefix || 'br',
open: false,
hidden: false,
status: 'idle',
statusDetail: '',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: true,
handsFreeMode: false,
useBrowserPreview: !!SpeechRecognition,
turns: [],
manualText: '',
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
sessionId: newSessionId(opts.sessionPrefix || 'br'),
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
holdIgnoreClick: false,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
initWidget: function () { /* overridden in browserWidget */ },
toggleOpen: function () {
this.open = !this.open;
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
},
scrollLog: function () {
var self = this;
this.$nextTick(function () {
var log = self.$refs.chatLog;
if (log) log.scrollTop = log.scrollHeight;
});
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.scrollLog();
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
},
closeResultsModal: function () { this.resultsOpen = false; },
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
},
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
this.manualText = 'ja';
this.sendText(this.pendingAction.id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendText();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '', final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
this.livePreview = '';
this.status = 'listening';
this.statusDetail = 'Luisteren…';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if (blob.size > 0) self.processVoice(blob);
else { self.status = 'idle'; self.statusDetail = ''; }
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast('Microfoon: ' + e.message, 'error');
}
},
stopListen: function () {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen: function () {
if (this.recording) this.stopListen();
else this.startListen();
},
micClass: function () {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
return '';
},
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
onMicUp: function () {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick: function () {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
async processVoice(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
var text = j.text || '';
this.livePreview = text;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'voice' });
this.handleHermanResponse(j.herman || {});
this.status = 'idle';
this.statusDetail = '';
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
async sendText(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
try {
var body = { message: text, channel: this.channel, session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.handleHermanResponse(r);
this.status = 'idle';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
}
this.busy = false;
},
formatDelegated: function (meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
}
return {
browserWidget: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
s.initWidget = function () {
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
try {
var raw = localStorage.getItem('herman_assistant_open');
if (raw === '1') this.open = true;
} catch (e) { /* empty */ }
};
return s;
},
chatPage: function () {
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
s.imagePrompt = '';
s.generating = false;
s.lastImage = '';
s.initChat = function () {
this.open = true;
};
s.generateImage = async function () {
if (!this.imagePrompt.trim()) return;
this.generating = true;
try {
var r = await Cockpit.api('/api/ai/generate-image', {
method: 'POST',
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 }),
});
if (r.proxy_url) {
this.lastImage = r.proxy_url;
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
}
Cockpit.toast('Afbeelding gegenereerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.generating = false;
};
return s;
},
entityTypeLabel: entityTypeLabel,
};
})();
function hermanBrowserWidget() {
return HermanAssistant.browserWidget();
}
+1
View File
File diff suppressed because one or more lines are too long
+9 -2
View File
@@ -17,7 +17,14 @@ window.I18n = (function () {
function applyDom() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n');
if (k) el.textContent = t(k);
if (!k) return;
var text = t(k);
/* Sidebar nav: never replace whole link (destroys icons) */
if (el.classList && el.classList.contains('nav-pill')) {
var lbl = el.querySelector('.sidebar-nav-label');
if (lbl) { lbl.textContent = text; return; }
}
el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
var k = el.getAttribute('data-i18n-placeholder');
@@ -31,7 +38,7 @@ window.I18n = (function () {
}
async function loadBundle(loc) {
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
var r = await fetch('/static/i18n/' + loc + '.json?v=3');
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
strings = await r.json();
}
+143
View File
@@ -0,0 +1,143 @@
(function () {
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function pageMatches(page, filters) {
const q = (filters.search || '').trim().toLowerCase();
const terms = filters.terms || [];
const siteId = filters.siteId ? String(filters.siteId) : '';
const minWords = parseInt(filters.minWords, 10) || 0;
const minHype = parseInt(filters.minHype, 10) || 0;
if (siteId && String(page.site_id) !== siteId) return false;
if ((page.word_count || 0) < minWords) return false;
if ((page.hype_score || 0) < minHype) return false;
if (filters.changedOnly && !page.recently_changed) return false;
const hay = [
page.site_name,
page.title,
page.url,
page.excerpt,
page.content_read,
page.meta_description,
(page.headings || []).join(' '),
(page.signals || []).join(' '),
(page.links_sample || []).map(function (l) {
return (l.label || '') + ' ' + (l.href || '');
}).join(' '),
]
.join(' ')
.toLowerCase();
if (q && hay.indexOf(q) < 0) return false;
for (let i = 0; i < terms.length; i++) {
if (hay.indexOf(String(terms[i]).toLowerCase()) < 0) return false;
}
return true;
}
function sortPages(pages, col, dir) {
const mul = dir === 'asc' ? 1 : -1;
const copy = pages.slice();
copy.sort(function (a, b) {
let va;
let vb;
switch (col) {
case 'site_name':
va = (a.site_name || '').toLowerCase();
vb = (b.site_name || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'title':
va = (a.title || a.url || '').toLowerCase();
vb = (b.title || b.url || '').toLowerCase();
return va < vb ? -mul : va > vb ? mul : 0;
case 'word_count':
return ((a.word_count || 0) - (b.word_count || 0)) * mul;
case 'links_count':
return ((a.links_count || 0) - (b.links_count || 0)) * mul;
case 'crawled_at':
va = a.crawled_at || '';
vb = b.crawled_at || '';
return va < vb ? -mul : va > vb ? mul : 0;
case 'hype_score':
default:
return ((a.hype_score || 0) - (b.hype_score || 0)) * mul;
}
});
return copy;
}
function filterPages(pages, filters) {
if (!pages || !pages.length) return [];
return pages.filter(function (p) {
return pageMatches(p, filters);
});
}
function highlightHtml(text, queries) {
const raw = text || '';
if (!raw) return '<span class="muted">Geen tekst</span>';
let html = esc(raw);
const list = [];
if (queries && queries.length) {
queries.forEach(function (q) {
if (q && String(q).trim()) list.push(String(q).trim());
});
}
list.sort(function (a, b) {
return b.length - a.length;
});
list.forEach(function (term) {
if (term.length < 2) return;
const re = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
html = html.replace(re, '<mark>$1</mark>');
});
return html;
}
function exportCsv(pages) {
const rows = [['site', 'title', 'url', 'words', 'hype', 'changed', 'crawled_at', 'excerpt']];
pages.forEach(function (p) {
rows.push([
p.site_name || '',
(p.title || '').replace(/"/g, '""'),
p.url || '',
String(p.word_count || 0),
String(p.hype_score || 0),
p.recently_changed ? 'yes' : 'no',
p.crawled_at || '',
(p.excerpt || '').replace(/"/g, '""').slice(0, 500),
]);
});
return rows
.map(function (r) {
return r.map(function (c) {
return '"' + c + '"';
}).join(',');
})
.join('\n');
}
function downloadCsv(pages, filename) {
const csv = exportCsv(pages);
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || 'parse-analyse.csv';
a.click();
URL.revokeObjectURL(a.href);
}
window.ParseIntel = {
filterPages: filterPages,
sortPages: sortPages,
highlightHtml: highlightHtml,
exportCsv: exportCsv,
downloadCsv: downloadCsv,
};
})();
+422
View File
@@ -0,0 +1,422 @@
function revenueCockpit() {
return {
loading: false,
saving: false,
savedAt: '',
viewMode: 'cockpit',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
sheetRows: [],
selectedId: null,
selectedKey: null,
expandedKey: null,
meta: {},
aggregates: {},
filterQ: '',
filterStyle: '',
filterCategory: '',
filterHasMargin: false,
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
taskForm: { agent_name: 'research', title: '', description: '' },
styleFilters: [
{ id: 'green', label: 'Live' },
{ id: 'red', label: 'Inactief' },
{ id: 'orange', label: 'Oranje' },
{ id: 'yellow', label: 'Strategisch' },
{ id: 'blue', label: 'Blauw' },
{ id: 'white', label: 'Neutraal' },
],
categoryFilters: [
{ id: 'deal', label: 'Deal' },
{ id: 'initiative', label: 'Initiatief' },
{ id: 'strategic', label: 'Strategisch' },
],
get selectedRow() {
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
},
get selectedProject() {
if (!this.selectedKey) return null;
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
},
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
await this.loadLive();
await this.loadDbQuiet();
},
projectKey(p) {
return String(p.source_row || '') + '|' + (p.name || '');
},
fmtNum(n) {
if (n == null || n === '' || isNaN(n)) return '';
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
fmtEuro(n) {
if (n == null || n === '' || isNaN(n)) return '—';
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
},
fmtEuroCompact(n) {
if (n == null || n === '' || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
return '€' + Math.round(v);
},
fmtDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return iso;
}
},
categoryLabel(c) {
if (c === 'deal') return 'Deal';
if (c === 'strategic') return 'Strategisch';
return 'Initiatief';
},
parseNum(text) {
const t = String(text || '').trim();
if (!t || t === '—') return null;
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
return isNaN(n) ? null : n;
},
filteredProjects() {
const q = (this.filterQ || '').trim().toLowerCase();
return this.projects.filter((p) => {
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
if (this.filterCategory && p.category !== this.filterCategory) return false;
if (this.filterHasMargin && !p.margin_month) return false;
if (q) {
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
clearFilters() {
this.filterQ = '';
this.filterStyle = '';
this.filterCategory = '';
this.filterHasMargin = false;
},
truncate(s, n) {
if (!s) return '';
const t = String(s).replace(/\s+/g, ' ').trim();
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
};
return map[style] || map.white;
},
foodlinkkIndexFrom(list) {
const idx = list.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : list.length;
},
visualRows() {
const items = this.filteredProjects();
const flIdx = this.foodlinkkIndexFrom(items);
return items.map((p, i) => {
let logoCell = null;
let brand = 'cucina';
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
if (i === 0) logoCell = 'cucina';
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
return { ...p, logoCell, brand };
});
},
maxMargin(field) {
const vals = this.filteredProjects().map((p) => p[field] || 0);
return Math.max(...vals, 1);
},
marginBarPct(p, field) {
if (!p[field]) return 0;
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
},
sharePct(p) {
const total = this.aggregates.total_margin_month || 0;
if (!p.margin_month || !total) return 0;
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
},
donutStyle(p) {
const pct = this.sharePct(p);
const color = this.styleColor(p.row_style);
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
const key = this.projectKey(p);
this.expandedKey = this.expandedKey === key ? null : key;
this.selectedKey = key;
this.taskForm.title = '';
this.taskForm.description = p.next_steps || '';
},
selectProject(p) {
this.toggleExpand(p);
},
mergeDbIds() {
const byName = {};
for (const d of this.dbProjects) {
const k = (d.name || '').trim().toLowerCase();
if (k) byName[k] = d.id;
}
this.projects = this.projects.map((p) => ({
...p,
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
}));
},
async loadLive() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
this.goals = r.goals || {};
this.projects = r.projects || [];
this.aggregates = {
...(r.aggregates || {}),
project_count: r.project_count || this.projects.length,
};
this.meta = {
source_file: r.source_file,
sheet_name: r.sheet_name,
file_mtime: r.file_mtime,
parsed_at: r.parsed_at,
};
this.mergeDbIds();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
}
this.loading = false;
},
async loadDbQuiet() {
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.dbProjects = r.projects || [];
this.mergeDbIds();
} catch (e) {
console.error(e);
}
},
foodlinkkIndex() {
const idx = this.dbProjects.findIndex((p) =>
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
/foodlinkk food marketing/i.test(p.name)
);
return idx >= 0 ? idx : this.dbProjects.length;
},
buildSheetRows() {
const flIdx = this.foodlinkkIndex();
this.sheetRows = this.dbProjects.map((p, i) => {
let logoCell = null;
let logoSpan = 1;
if (i === 0) {
logoCell = 'cucina';
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
} else if (i === flIdx && flIdx < this.dbProjects.length) {
logoCell = 'foodlinkk';
logoSpan = this.dbProjects.length - flIdx;
}
return { ...p, logoCell, logoSpan };
});
},
async switchToSheet() {
this.viewMode = 'sheet';
await this.loadDb();
},
async loadDb() {
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
this.goals = r.stats?.goals || this.goals;
this.dbProjects = r.projects || [];
this.buildSheetRows();
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
}
this.loading = false;
},
editStart(ev) {
const el = ev.target;
if (el.classList.contains('rc-cell-num')) {
const raw = el.textContent.trim();
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
}
requestAnimationFrame(() => {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
});
},
markSaved() {
const now = new Date();
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
async saveGoalsField(field, el) {
const val = el.innerText.trim();
if (this.goals[field] === val) return;
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/goals', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
}).then((x) => x.json());
this.goals = r.goals || this.goals;
this.markSaved();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
async saveProjectField(row, field, el) {
let val;
if (field === 'margin_month' || field === 'margin_year') {
val = this.parseNum(el.innerText);
el.textContent = this.fmtNum(val);
} else {
val = el.innerText.trim();
}
if (String(row[field] ?? '') === String(val ?? '')) return;
const body = { [field]: val };
if (field === 'margin_month' && val != null) {
body.margin_year = val * 12;
}
this.saving = true;
try {
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
const updated = r.project || {};
Object.assign(row, updated);
row.row_style = updated.row_style || row.row_style;
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
if (field === 'margin_month' && body.margin_year != null) {
const yearCell = el.nextElementSibling;
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
row.margin_year = body.margin_year;
}
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
}
this.saving = false;
},
selectRow(row) {
this.selectedId = row.id;
this.taskForm.title = '';
this.taskForm.description = row.next_steps || '';
},
async setRowStyle(style) {
if (!this.selectedRow) return;
try {
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
this.selectedRow.row_style = style;
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
if (pi >= 0) this.dbProjects[pi].row_style = style;
this.buildSheetRows();
this.markSaved();
await this.loadLive();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
async assignTask() {
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
const pid = row?.id || row?.db_id;
if (!pid || !this.taskForm.title.trim()) return;
try {
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: this.taskForm.agent_name,
title: this.taskForm.title.trim(),
description: this.taskForm.description,
delegate_herman: true,
}),
});
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
this.taskForm.title = '';
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
}
},
async takeSnapshot() {
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
},
async importExcel() {
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
this.loading = true;
try {
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ replace: true }),
}).then((x) => x.json());
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
await this.loadLive();
await this.loadDbQuiet();
if (this.viewMode === 'sheet') await this.loadDb();
} catch (e) {
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
}
this.loading = false;
},
};
}
+464
View File
@@ -0,0 +1,464 @@
/**
* Voice Live — push-to-talk, hands-free, export results popup, Herman bevestiging
*/
window.VoiceLive = (function () {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function nowTime() {
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function entityTypeLabel(t) {
var m = {
distributor: 'Distributeur',
wholesaler: 'Groothandel',
importer: 'Importeur',
logistics: 'Logistiek',
restaurant: 'Restaurant',
caterer: 'Cateraar',
butcher: 'Slager',
doner: 'Döner',
};
return m[t] || t || '—';
}
return {
page: function () {
return {
status: 'idle',
statusDetail: 'Houd de microfoon ingedrukt en spreek',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
conversationMode: true,
handsFreeMode: false,
speakReply: true,
useBrowserPreview: !!SpeechRecognition,
pipeline: [],
feed: [],
turns: [],
manualText: '',
holdIgnoreClick: false,
sessionId: 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
pendingAction: null,
resultsOpen: false,
resultsTitle: '',
resultsTotal: 0,
resultsEntities: [],
resultsOpenUrl: '',
webbuilderOpen: false,
webbuilderTitle: '',
webbuilderProject: '',
webbuilderPreview: '',
webbuilderAgentsUrl: '/agents',
webbuilderNas: '',
mediaRecorder: null,
chunks: [],
stream: null,
recognition: null,
agentEs: null,
_resumeTimer: null,
_silenceTimer: null,
_maxRecordTimer: null,
init: function () {
var self = this;
this.connectAgentFeed();
window.addEventListener('keydown', function (e) {
if (e.code === 'Space' && !e.target.matches('input, textarea') && !self.recording && !self.busy) {
e.preventDefault();
self.startListen();
}
});
window.addEventListener('keyup', function (e) {
if (e.code === 'Space' && self.recording && !e.target.matches('input, textarea')) {
e.preventDefault();
self.stopListen();
}
});
},
destroy: function () {
if (this._resumeTimer) clearTimeout(this._resumeTimer);
if (this._silenceTimer) clearTimeout(this._silenceTimer);
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
if (this.agentEs) {
this.agentEs.close();
this.agentEs = null;
}
this._stopRecognition();
this._stopStream();
},
connectAgentFeed: function () {
var self = this;
if (typeof EventSource === 'undefined') return;
try {
this.agentEs = new EventSource('/api/agents/live/stream');
this.agentEs.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (!data || data.type === 'connected') return;
var ch = (data.channel || '').toLowerCase();
var agent = (data.agent || data.agent_name || '').toLowerCase();
if (ch !== 'voice' && agent !== 'herman' && agent !== 'voice' && agent !== 'sourcing') return;
self.feed.unshift({
at: nowTime(),
agent: data.agent || data.agent_name || 'agent',
message: data.message || data.title || '',
});
if (self.feed.length > 40) self.feed.pop();
} catch (e) { /* empty */ }
};
} catch (e) { /* empty */ }
},
pushPipeline: function (entries) {
if (!entries || !entries.length) return;
this.pipeline = this.pipeline.concat(entries);
if (this.pipeline.length > 60) this.pipeline = this.pipeline.slice(-60);
},
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.$nextTick(function () {
var log = document.getElementById('voice-chat-log');
if (log) log.scrollTop = log.scrollHeight;
});
},
handleHermanResponse: function (h) {
if (!h) return;
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
needs_confirmation: h.needs_confirmation,
});
}
var actions = h.ui_actions || [];
for (var i = 0; i < actions.length; i++) {
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
}
if (!actions.length && h.webbuilder_preview_url) {
this.openWebbuilderModal({
type: 'open_webbuilder_build',
title: 'Website build — ' + (h.webbuilder_project || 'project'),
project: h.webbuilder_project,
preview_url: h.webbuilder_preview_url,
agents_url: '/agents',
});
}
if (this.speakReply && h.reply && window.speechSynthesis) {
var self = this;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
}
},
maybeResumeListening: function (delay) {
var self = this;
if (!this.handsFreeMode || this.busy || this.recording) return;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function () {
if (self.handsFreeMode && !self.busy && !self.recording && self.status !== 'error') {
self.statusDetail = 'Hands-free — luisteren…';
self.startListen();
}
}, delay || 500);
},
openResultsModal: function (action) {
this.resultsTitle = action.title || 'Export Intel resultaten';
this.resultsEntities = action.entities || [];
this.resultsTotal = action.total || this.resultsEntities.length;
this.resultsOpenUrl = action.open_url || '/export-intel';
this.resultsOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Popup geopend',
detail: this.resultsTotal + ' resultaten',
status: 'done',
at: new Date().toISOString(),
}]);
},
closeResultsModal: function () {
this.resultsOpen = false;
},
openWebbuilderModal: function (action) {
this.webbuilderTitle = action.title || 'Website build';
this.webbuilderProject = action.project || '';
this.webbuilderPreview = action.preview_url || '';
this.webbuilderAgentsUrl = action.agents_url || '/agents';
this.webbuilderNas = action.nas_path || '';
this.webbuilderOpen = true;
this.pushPipeline([{
phase: 'ui',
label: 'Agy build gestart',
detail: this.webbuilderProject,
status: 'running',
at: new Date().toISOString(),
}]);
},
closeWebbuilderModal: function () {
this.webbuilderOpen = false;
},
confirmPending: function () {
if (!this.pendingAction || !this.pendingAction.id) return;
var id = this.pendingAction.id;
this.manualText = 'ja';
this.sendManual(id);
},
cancelPending: function () {
this.pendingAction = null;
this.manualText = 'nee';
this.sendManual();
},
entityTypeLabel: entityTypeLabel,
_startRecognition: function () {
if (!this.useBrowserPreview || !SpeechRecognition) return;
var self = this;
try {
this.recognition = new SpeechRecognition();
this.recognition.lang = 'nl-NL';
this.recognition.interimResults = true;
this.recognition.continuous = true;
this.recognition.onresult = function (e) {
var interim = '';
var final = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
self.liveInterim = !!interim && !final;
self.livePreview = final || interim;
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
};
this.recognition.onerror = function () { /* optional */ };
this.recognition.start();
} catch (e) { /* empty */ }
},
_stopRecognition: function () {
if (this.recognition) {
try { this.recognition.stop(); } catch (e) { /* empty */ }
this.recognition = null;
}
},
_stopStream: function () {
if (this.stream) {
this.stream.getTracks().forEach(function (t) { t.stop(); });
this.stream = null;
}
},
_scheduleSilenceStop: function () {
var self = this;
if (this._silenceTimer) clearTimeout(this._silenceTimer);
this._silenceTimer = setTimeout(function () {
if (self.recording && self.handsFreeMode) self.stopListen();
}, 1600);
},
async startListen() {
if (this.recording || this.busy) return;
var self = this;
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this.livePreview = '';
this.liveInterim = false;
if (!this.handsFreeMode) this.pipeline = [];
this.status = 'listening';
this.statusDetail = this.handsFreeMode
? 'Hands-free — spreek je vraag'
: 'Spreek nu — laat los om te versturen';
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size) self.chunks.push(e.data);
};
this.mediaRecorder.onstop = function () {
self._stopRecognition();
self._stopStream();
if (self._silenceTimer) clearTimeout(self._silenceTimer);
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
var blob = new Blob(self.chunks, { type: 'audio/webm' });
self.recording = false;
if ((self.conversationMode || self.handsFreeMode) && blob.size > 0) self.processTurn(blob);
else if (blob.size > 0) {
self.status = 'idle';
self.statusDetail = 'Opname klaar';
}
};
this.mediaRecorder.start(250);
this.recording = true;
this._startRecognition();
if (this.handsFreeMode) {
var self = this;
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
this._maxRecordTimer = setTimeout(function () {
if (self.recording) self.stopListen();
}, 18000);
}
} catch (e) {
this.status = 'error';
this.statusDetail = 'Microfoon: ' + e.message;
Cockpit.toast(this.statusDetail, 'error');
}
},
stopListen() {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
},
toggleListen() {
if (this.recording) this.stopListen();
else this.startListen();
},
toggleHandsFree() {
if (this.handsFreeMode && !this.recording && !this.busy) {
Cockpit.toast('Hands-free aan — ik luister na elk antwoord opnieuw', 'success');
this.startListen();
} else if (!this.handsFreeMode && this.recording) {
this.stopListen();
}
},
onHandsFreeChange() {
if (this.handsFreeMode) this.toggleHandsFree();
else if (this.recording) this.stopListen();
},
async processTurn(blob) {
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Whisper + Herman…';
this.pushPipeline([{
phase: 'capture',
label: 'Audio verstuurd',
detail: Math.round(blob.size / 1024) + ' KB',
status: 'done',
at: new Date().toISOString(),
}]);
try {
var fd = new FormData();
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
fd.append('session_id', this.sessionId);
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
var j = await r.json();
if (!r.ok) throw new Error(j.detail || j.error || 'Voice turn mislukt');
this.pushPipeline(j.pipeline || []);
var text = j.text || '';
this.livePreview = text;
this.liveInterim = false;
this.manualText = text;
if (text) this.addTurn('user', text, { source: 'whisper' });
var h = j.herman || {};
this.handleHermanResponse(h);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar — spreek opnieuw';
if (!h.needs_confirmation && h.reply) Cockpit.toast('Herman antwoordde', 'success');
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
this.pushPipeline([{ phase: 'error', label: 'Fout', detail: e.message, status: 'error', at: new Date().toISOString() }]);
Cockpit.toast(e.message, 'error');
this.maybeResumeListening(1500);
}
this.busy = false;
},
async sendManual(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Herman denkt na…';
var confirmId = confirmActionId || (this.pendingAction && this.pendingAction.id) || null;
if (confirmId && (text.toLowerCase() === 'ja' || confirmActionId)) {
/* confirm via button passes id explicitly */
}
this.pushPipeline([{ phase: 'herman', label: 'Tekst naar Herman', detail: text.slice(0, 120), status: 'running', at: new Date().toISOString() }]);
try {
var body = { message: text, channel: 'voice', session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
if (this.pipeline.length) this.pipeline[this.pipeline.length - 1].status = 'done';
this.addTurn('user', text, { source: 'typed' });
this.handleHermanResponse(r);
this.status = 'idle';
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
this.statusDetail = e.message;
}
this.busy = false;
},
clearSession() {
this.turns = [];
this.pipeline = [];
this.feed = [];
this.livePreview = '';
this.manualText = '';
this.pendingAction = null;
this.resultsOpen = false;
this.sessionId = 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
this.status = 'idle';
this.statusDetail = 'Sessie gewist';
},
micClass() {
if (this.recording) return 'is-listening';
if (this.busy) return 'is-busy';
if (this.handsFreeMode) return 'is-handsfree';
return '';
},
onMicDown() { this.holdIgnoreClick = true; this.startListen(); },
onMicUp() {
if (this.recording && !this.handsFreeMode) this.stopListen();
var self = this;
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
},
onMicClick() {
if (this.holdIgnoreClick) return;
this.toggleListen();
},
formatDelegated(meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
},
};
},
};
})();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "Foodlinkk Command Center",
"name": "Cucina · Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "Herman CEO dashboard · CRM · Retail · Marketing",
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
"start_url": "/",
"scope": "/",
"display": "standalone",
+15 -43
View File
@@ -1,46 +1,18 @@
/* Foodlinkk PWA — light cache for shell */
const CACHE = 'foodlinkk-v1';
const ASSETS = [
'/',
'/static/css/palantir-theme.css',
'/static/css/pulse-theme.css',
'/static/css/mobile.css',
'/static/css/vertical-tabs.css',
'/static/js/cockpit.js',
'/static/js/live-pulse.js',
'/static/icons/app-icon.svg',
'/static/manifest.json',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(ASSETS).catch(() => {}))
);
/* PWA cache disabled — clears legacy caches and unregisters itself */
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
const url = new URL(e.request.url);
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
e.respondWith(
fetch(e.request)
.then((r) => {
if (r.ok && url.pathname.startsWith('/static/')) {
const clone = r.clone();
caches.open(CACHE).then((c) => c.put(e.request, clone));
}
return r;
})
.catch(() => caches.match(e.request).then((m) => m || caches.match('/')))
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
/* Do not intercept fetches — always use network */
self.addEventListener('fetch', () => {});
+211 -13
View File
@@ -3,7 +3,7 @@
<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=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
<script src="/static/js/agent-avatars.js?v=3"></script>
{% endblock %}
@@ -25,12 +25,16 @@
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
<div class="vtabs-nav-header"><strong>Agents</strong></div>
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
<span class="vtab-icon">🧬</span><span>Souls</span>
</button>
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
</button>
<button class="vtab-btn" :class="{active: tab==='terminals'}" @click="setTab('terminals', $el)" data-tab="terminals">
<span class="vtab-icon">🖥️</span><span>Terminals</span>
</button>
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
<span class="vtab-icon">✓</span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
@@ -62,6 +66,9 @@
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
<p class="agent-role-desc" x-text="s.responsibilities || '—'"></p>
<div class="agent-meta-row">
<button type="button" class="agent-events-link"
@click.stop="goTerminal(s.agent_key)"
title="Open live terminal">Terminal →</button>
<button type="button" class="agent-events-link"
@click.stop="openEvents(s.agent_key, s.display_name)"
x-text="(s.event_count||0) + ' events'"
@@ -105,18 +112,114 @@
</div>
</section>
<section x-show="tab==='mesh'" x-cloak>
<section x-show="tab==='mesh'" x-cloak
x-effect="if (tab === 'mesh') { $nextTick(() => mountMesh()) }">
<div class="panel">
<h2 style="margin-top:0;">Netwerk</h2>
<p class="page-tip" style="margin-top:.25rem;">Agents onderaan → Herman (Co-CEO) → CEO &amp; CTO. Pulse = actieve communicatie.</p>
<div class="mesh-framework-head">
<h2 style="margin-top:0;">Agent Communicatie Framework</h2>
<p class="page-tip" style="margin-top:.25rem;">Kies hoe je het netwerk wilt zien. Klik een agent → live terminal. Alle terminals onder tab <strong>Terminals</strong>.</p>
</div>
<div class="mesh-mode-section" id="mesh-mode-picker">
<label>Communicatiestijl</label>
<div class="mesh-mode-grid">
<button type="button" class="mesh-mode-card" data-mesh-mode="pyramid" aria-pressed="false"
onclick="window.AgentsMesh&&AgentsMesh.pickMode('pyramid')">
<span class="mesh-mode-check" aria-hidden="true"></span>
<span class="mesh-mode-icon">◆</span>
<span class="mesh-mode-title">Piramide</span>
<span class="mesh-mode-sub">Herman als hub</span>
<span class="mesh-mode-desc">Rapport ↑ en delegatie ↓. Alleen live handoffs.</span>
</button>
<button type="button" class="mesh-mode-card" data-mesh-mode="mesh" aria-pressed="false"
onclick="window.AgentsMesh&&AgentsMesh.pickMode('mesh')">
<span class="mesh-mode-check" aria-hidden="true"></span>
<span class="mesh-mode-icon">⬡</span>
<span class="mesh-mode-title">Vol netwerk</span>
<span class="mesh-mode-sub">Alle partners</span>
<span class="mesh-mode-desc">Volledige samenwerkingsmatrix zichtbaar.</span>
</button>
<button type="button" class="mesh-mode-card" data-mesh-mode="org" aria-pressed="false"
onclick="window.AgentsMesh&&AgentsMesh.pickMode('org')">
<span class="mesh-mode-check" aria-hidden="true"></span>
<span class="mesh-mode-icon">▤</span>
<span class="mesh-mode-title">Organigram</span>
<span class="mesh-mode-sub">Pure hiërarchie</span>
<span class="mesh-mode-desc">CEO → Herman → agents, geen peer-lijnen.</span>
</button>
<button type="button" class="mesh-mode-card" data-mesh-mode="pulse" aria-pressed="false"
onclick="window.AgentsMesh&&AgentsMesh.pickMode('pulse')">
<span class="mesh-mode-check" aria-hidden="true"></span>
<span class="mesh-mode-icon">◎</span>
<span class="mesh-mode-title">Live stream</span>
<span class="mesh-mode-sub">Alleen actief</span>
<span class="mesh-mode-desc">Geen stille lijnen — alleen wat nu beweegt.</span>
</button>
<button type="button" class="mesh-mode-card" data-mesh-mode="clusters" aria-pressed="false"
onclick="window.AgentsMesh&&AgentsMesh.pickMode('clusters')">
<span class="mesh-mode-check" aria-hidden="true"></span>
<span class="mesh-mode-icon">◉</span>
<span class="mesh-mode-title">Domeinen</span>
<span class="mesh-mode-sub">Per afdeling</span>
<span class="mesh-mode-desc">Groei · Product · Operatie gegroepeerd.</span>
</button>
</div>
</div>
<div class="mesh-toolbar">
<span id="mesh-mode-badge" class="mesh-mode-badge">Piramide</span>
<button type="button" class="btn btn-sm" @click="remountMesh()">↻ Verversen</button>
<button type="button" class="btn btn-sm btn-pulse" @click="setTab('terminals', $refs.vtabsNav?.querySelector('[data-tab=terminals]'))">Alle terminals</button>
</div>
<div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1200 860"></svg>
</div>
<div class="mesh-legend">
<span>Groen = healthy</span>
<span>Oranje = waarschuwing</span>
<span>Blauw = idle</span>
<span>Grijs = offline</span>
<span class="leg-report">↑ Rapport</span>
<span class="leg-delegate">↓ Delegatie</span>
<span class="leg-peer" x-show="meshLegendPeer">↔ Handoff</span>
<span class="leg-exec" x-show="meshLegendExec">⇅ Executive</span>
</div>
<div class="mesh-terminal-focus" x-show="focusTerminalKey" x-cloak>
<div class="agent-term-head">
<span class="term-dot"></span>
<strong x-text="'Terminal · ' + (focusTerminalName || '')"></strong>
<button type="button" class="btn btn-sm" @click="clearFocusTerminal()">Sluiten</button>
</div>
<div class="agent-term-body" :data-term-key="focusTerminalKey" data-term-focus="1"></div>
</div>
</div>
</section>
<section x-show="tab==='terminals'" x-cloak>
<div class="panel">
<h2 style="margin-top:0;">Agent terminals</h2>
<p class="agents-terminals-intro">Elke agent heeft een eigen terminal. Typ commando's en druk <kbd>Enter</kbd> — output verschijnt live. Start met <code>help</code>.</p>
<div class="agents-terminals-grid">
<template x-for="s in souls" :key="'term-' + s.agent_key">
<article class="agent-term-window"
:class="{ 'is-live': (s.events_6h||0) > 0 || s.current_task, 'is-expanded': expandedTerminal === s.agent_key }"
@click="expandTerminal(s.agent_key)">
<header class="agent-term-head">
<span class="term-dot"></span>
<strong x-text="s.display_name"></strong>
<small x-text="s.current_task || s.last_event_title || 'Standby'"></small>
</header>
<div class="agent-term-body" :data-term-key="s.agent_key"></div>
<footer class="agent-term-foot" @click.stop>
<label class="agent-term-cmd" :for="'term-in-' + s.agent_key">
<span class="term-prompt" x-text="s.agent_key + '>'"></span>
<input type="text"
:id="'term-in-' + s.agent_key"
class="agent-term-input"
:data-term-input="s.agent_key"
:placeholder="s.agent_key === 'herman' ? 'briefing · vraag aan Herman…' : (s.agent_key === 'webbuilder' ? 'build <opdracht> · projects · status …' : 'help · status · handoff …')"
autocomplete="off"
spellcheck="false" />
</label>
</footer>
</article>
</template>
</div>
</div>
</section>
@@ -196,7 +299,8 @@
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script>
function agentsSoulHub() {
return {
@@ -207,6 +311,11 @@ function agentsSoulHub() {
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false,
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
terminalOpen: false, terminalAgentKey: null, terminalAgentName: '', terminalLines: [], _terminalEs: null,
meshCommMode: 'pyramid',
meshLegendPeer: true,
meshLegendExec: true,
focusTerminalKey: null, focusTerminalName: '', expandedTerminal: null,
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
statusClass(s) {
@@ -260,6 +369,7 @@ function agentsSoulHub() {
return 'Standby';
},
async init() {
this.syncMeshMode();
await this.load();
await this.loadPendingCount();
if (window.PageViz) {
@@ -268,7 +378,17 @@ function agentsSoulHub() {
this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn);
const self = this;
if (window.AgentsMesh) {
AgentsMesh.bindModePicker({
onNodeClick: function (node) {
self.focusTerminal(node.agent_key, node.display_name);
},
});
}
if (window.AgentsTerminals) AgentsTerminals.startLiveStream();
if (this.tab === 'mesh') this.mountMesh();
if (this.tab === 'terminals') this.startTerminals();
});
},
updateTabIndicator(el) {
@@ -285,6 +405,7 @@ function agentsSoulHub() {
url.searchParams.set('tab', next);
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
if (next === 'terminals') this.startTerminals();
if (next === 'approvals') this.loadPendingCount();
},
async loadPendingCount() {
@@ -293,10 +414,87 @@ function agentsSoulHub() {
this.pendingCount = (r.items || []).length;
} catch (e) { this.pendingCount = 0; }
},
syncMeshMode() {
if (!window.AgentsMesh) return;
const self = this;
this.meshCommMode = AgentsMesh.getSavedMode();
this.updateMeshLegend();
AgentsMesh.setModeChangeCallback(function (id) {
self.meshCommMode = id;
self.updateMeshLegend();
});
},
updateMeshLegend() {
const m = (window.AgentsMesh && AgentsMesh.COMM_MODES) ? AgentsMesh.COMM_MODES[this.meshCommMode] : null;
this.meshLegendPeer = m ? (m.showLivePeers || m.showStaticPeers) : true;
this.meshLegendExec = m ? m.showExecutive !== false : true;
},
setMeshMode(id) {
if (window.AgentsMesh) AgentsMesh.pickMode(id);
this.meshCommMode = id;
this.updateMeshLegend();
},
remountMesh() {
if (window.AgentsMesh && AgentsMesh.refresh) {
const self = this;
AgentsMesh.refresh('agents-mesh-canvas', {
commMode: this.meshCommMode,
onNodeClick: function (node) {
self.focusTerminal(node.agent_key, node.display_name);
},
});
} else {
this.mountMesh();
}
},
mountMesh() {
if (this.meshLoaded || !window.AgentsMesh) return;
this.meshLoaded = true;
window.AgentsMesh.mount('agents-mesh-canvas');
if (!window.AgentsMesh) return;
const self = this;
const opts = {
commMode: this.meshCommMode,
onNodeClick: function (node) {
self.focusTerminal(node.agent_key, node.display_name);
},
};
window.AgentsMesh.mount('agents-mesh-canvas', opts).catch(function (e) {
if (window.Cockpit) Cockpit.toast('Mesh: ' + (e.message || 'laden mislukt'), 'error');
});
if (this.focusTerminalKey && window.AgentsTerminals) {
this.$nextTick(function () { AgentsTerminals.mountAll(); });
}
},
focusTerminal(key, name) {
this.focusTerminalKey = key;
this.focusTerminalName = name || key;
this.expandedTerminal = key;
this.$nextTick(function () {
if (window.AgentsTerminals) AgentsTerminals.mountAll();
});
},
clearFocusTerminal() {
this.focusTerminalKey = null;
this.focusTerminalName = '';
},
goTerminal(key) {
const soul = this.souls.find(function (s) { return s.agent_key === key; });
const btn = this.$refs.vtabsNav?.querySelector('[data-tab=terminals]');
this.setTab('terminals', btn);
this.expandedTerminal = key;
this.$nextTick(function () {
const el = document.querySelector('[data-term-key="' + key + '"]');
if (el) el.closest('.agent-term-window')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
},
expandTerminal(key) {
this.expandedTerminal = this.expandedTerminal === key ? null : key;
},
startTerminals() {
this.$nextTick(function () {
if (window.AgentsTerminals) {
AgentsTerminals.mountAll();
AgentsTerminals.bindInputs();
}
});
},
async load() {
const r = await fetch('/api/agents/souls').then(x => x.json());
+198 -57
View File
@@ -8,71 +8,74 @@
<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 }} · Foodlinkk{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json?v=1" />
<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=3" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=8" />
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=18" />
<script defer src="/static/js/alpine.min.js?v=2"></script>
<script defer src="/static/js/htmx.min.js?v=2"></script>
{% block extra_head %}{% endblock %}
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=6" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" />
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen }">
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
<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">Foodlinkk</p>
<p class="brand-name">Command Center</p>
<p class="brand-title">Cucina · Foodlinkk</p>
<p class="brand-name" x-text="brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'">Cucina B2C</p>
</div>
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
<div class="persona-switch brand-switch" role="tablist" aria-label="Brand">
<button type="button" role="tab" class="persona-btn brand-btn-cucina"
:class="{ active: brand === 'cucina' }" @click="setBrand('cucina')"
:aria-selected="brand === 'cucina'">Cucina · B2C</button>
<button type="button" role="tab" class="persona-btn brand-btn-foodlinkk"
:class="{ active: brand === 'foodlinkk' }" @click="setBrand('foodlinkk')"
:aria-selected="brand === 'foodlinkk'">Foodlinkk · B2B</button>
</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="persona-nav">
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
<div class="app-sidebar-label">Foodlinkk B2B</div>
<a href="/export-intel" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}"><span class="sidebar-nav-icon">💬</span><span class="sidebar-nav-label" data-i18n="nav.chat">Herman</span></a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></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="/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="/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" x-show="brand === 'cucina'">
<div class="app-sidebar-label">Cucina B2C</div>
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></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.agents">Agents</div>
<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="/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="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</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>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.system">Systeem</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
<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="/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>
<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 %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
</div>
</div>
</aside>
@@ -81,7 +84,10 @@
<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">Foodlinkk</span>
<span class="page-tip">
<span class="persona-badge" :class="brand === 'foodlinkk' ? 'brand-badge-foodlinkk' : 'brand-badge-cucina'" x-text="brand === 'foodlinkk' ? 'B2B' : 'B2C'"></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>
@@ -99,15 +105,117 @@
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/i18n.js?v=2"></script>
<!-- Herman browser assistant (elke pagina) -->
<div x-data="hermanBrowserWidget()" x-init="initWidget()" x-show="!hidden" x-cloak>
<button type="button" class="ha-fab" :class="{ 'is-open': open }" @click="toggleOpen()" title="Praat met Herman" aria-label="Herman assistant">💬</button>
<div class="ha-panel" x-show="open" x-transition @click.outside="open = false">
<header class="ha-head">
<h2>Herman <span style="font-size:0.65rem;color:#a78bfa">browser</span></h2>
<div class="ha-head-actions">
<a href="/voice">Voice live</a>
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
</div>
</header>
<div class="ha-confirm" x-show="pendingAction">
<span x-text="pendingAction ? (pendingAction.type === 'webbuilder_build' ? ('Agy website: ' + (pendingAction.project || '')) : ('Zoeken: ' + (pendingAction.entity_label || '') + ' in ' + (pendingAction.location_label || ''))) : ''"></span>
<div class="ha-confirm-actions">
<button type="button" class="btn btn-sm btn-pulse" @click="confirmPending()" :disabled="busy">Ja</button>
<button type="button" class="btn btn-sm" @click="cancelPending()" :disabled="busy">Nee</button>
</div>
</div>
<div class="ha-log" x-ref="chatLog">
<template x-for="(t, i) in turns" :key="i">
<div class="ha-bubble" :class="t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'">
<div class="ha-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : ' · Herman')"></div>
<p x-text="t.text"></p>
</div>
</template>
<p x-show="!turns.length" class="page-tip" style="font-size:0.75rem;margin:0">Typ of spreek — bv. “zoek distributeurs in Dubai”</p>
</div>
<div class="ha-preview" :class="{ 'is-interim': liveInterim }" x-show="livePreview || recording" x-text="livePreview || 'Luisteren…'"></div>
<p class="ha-status" x-show="statusDetail" x-text="statusDetail"></p>
<div class="ha-input-row">
<button type="button" class="ha-mic" :class="micClass()"
@mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()"
@touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()"
@click.prevent="onMicClick()" title="Microfoon">🎙️</button>
<input class="form-input" type="text" placeholder="Vraag aan Herman…" x-model="manualText"
@keydown.enter.prevent="sendText()" :disabled="busy" />
<button type="button" class="btn btn-sm btn-pulse" @click="sendText()" :disabled="busy || !manualText.trim()">→</button>
</div>
</div>
<div class="ei-modal-root ha-results-modal" x-show="resultsOpen" @click.self="closeResultsModal()">
<div class="ei-modal-card ha-results-card" @click.stop>
<h3 x-text="resultsTitle"></h3>
<p><strong x-text="resultsTotal"></strong> resultaten</p>
<div class="voice-results-table-wrap" style="max-height:40vh;overflow:auto">
<table class="voice-results-table" style="width:100%;font-size:0.78rem">
<thead><tr><th>Naam</th><th>Type</th><th>Stad</th></tr></thead>
<tbody>
<template x-for="(e, i) in resultsEntities" :key="i">
<tr>
<td><a :href="'/export-intel?open=' + e.id" x-text="e.name"></a></td>
<td x-text="entityTypeLabel(e.entity_type)"></td>
<td x-text="e.city || '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="ei-modal-actions">
<a class="btn btn-sm" :href="resultsOpenUrl">Wereldexport</a>
<button type="button" class="btn btn-sm" @click="closeResultsModal()">Sluiten</button>
</div>
</div>
</div>
<div class="ei-modal-root ha-results-modal" x-show="webbuilderOpen" @click.self="closeWebbuilderModal()">
<div class="ei-modal-card ha-results-card" @click.stop>
<h3 x-text="webbuilderTitle"></h3>
<p><strong x-text="webbuilderProject"></strong> · Agy / Antigravity op Hermes</p>
<p class="page-tip" style="font-size:0.78rem">Preview na afloop: <a :href="webbuilderPreview" target="_blank" rel="noopener" x-text="webbuilderPreview"></a></p>
<p class="page-tip" x-show="webbuilderNas" style="font-size:0.72rem" x-text="'NAS: ' + webbuilderNas"></p>
<div class="ei-modal-actions">
<a class="btn btn-sm btn-pulse" :href="webbuilderAgentsUrl">Agents terminal</a>
<a class="btn btn-sm" :href="webbuilderPreview" target="_blank" rel="noopener">Preview</a>
<button type="button" class="btn btn-sm" @click="closeWebbuilderModal()">Sluiten</button>
</div>
</div>
</div>
</div>
<script defer src="/static/js/herman-assistant.js?v=2"></script>
<script src="/static/js/i18n.js?v=5"></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.FoodlinkkBrand = (function () {
var FOODLINKK_PREFIXES = ['/export-intel', '/foodlinkk', '/clients', '/deals', '/products', '/suppliers'];
var CUCINA_PREFIXES = ['/retail', '/marketing', '/packaging', '/revenue-cockpit', '/beurs'];
function isFoodlinkkPath(path) {
return FOODLINKK_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
}
function isCucinaPath(path) {
return path === '/' || CUCINA_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
}
function detect(path) {
var stored = localStorage.getItem('foodlinkk-brand');
if (stored === 'cucina' || stored === 'foodlinkk') return stored;
if (isFoodlinkkPath(path)) return 'foodlinkk';
return 'cucina';
}
return { isFoodlinkkPath: isFoodlinkkPath, isCucinaPath: isCucinaPath, detect: detect };
})();
/* legacy persona helper → brand */
window.FoodlinkkPersona = window.FoodlinkkBrand;
function appShell() {
return {
locale: 'nl',
brand: 'cucina',
drawerOpen: false,
navOpen: false,
showInstall: false,
@@ -116,11 +224,37 @@
clockTime: '—',
_offset: 0,
async initApp() {
this.brand = window.FoodlinkkBrand.detect(location.pathname);
if (!localStorage.getItem('flk-cache-v3')) {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
if (window.caches) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
localStorage.setItem('flk-cache-v3', '1');
}
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock();
this.initPwa();
},
setBrand(b) {
if (b === this.brand) return;
this.brand = b;
localStorage.setItem('foodlinkk-brand', b);
var path = location.pathname;
if (b === 'foodlinkk' && window.FoodlinkkBrand.isCucinaPath(path) && !window.FoodlinkkBrand.isFoodlinkkPath(path)) {
location.href = '/export-intel';
return;
}
if (b === 'cucina' && window.FoodlinkkBrand.isFoodlinkkPath(path)) {
location.href = '/';
return;
}
},
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
@@ -148,9 +282,6 @@
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) {
@@ -171,6 +302,16 @@
};
}
</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>
+473 -13
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/parse-intel.css?v=1" />
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
<style>
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
@@ -8,6 +9,36 @@
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
}
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
.parse-result-card { border: 1px solid rgba(148,163,184,.14); border-radius: 10px; padding: .75rem .85rem; margin-bottom: .65rem; background: rgba(7,11,18,.6); }
.parse-result-card header { display:flex; flex-direction:column; gap:.15rem; margin-bottom:.45rem; }
.parse-excerpt { color:#94a3b8; font-size:.82rem; line-height:1.45; margin:0 0 .45rem; }
.parse-headings { display:flex; flex-wrap:wrap; gap:.35rem; margin-bottom:.4rem; }
.parse-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(0,229,255,.08); color:#7dd3fc; border:1px solid rgba(0,229,255,.2); }
.parse-links { margin:.25rem 0 0; padding-left:1rem; font-size:.78rem; color:#cbd5e1; }
.parse-links a { color:#86efac; }
.intel-kpi-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.65rem; margin-bottom:1rem; }
@media (max-width:900px){ .intel-kpi-grid { grid-template-columns:repeat(2,1fr); } }
.intel-kpi { padding:.75rem; border-radius:10px; border:1px solid rgba(148,163,184,.14); background:rgba(7,11,18,.65); }
.intel-kpi .lbl { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.04em; }
.intel-kpi .val { font-size:1.35rem; font-weight:700; color:#f1f5f9; margin-top:.15rem; }
.intel-toolbar { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:1rem; }
.intel-hype-cloud { display:flex; flex-wrap:wrap; gap:.45rem; margin:.5rem 0 1rem; }
.hype-chip { font-size:.78rem; padding:.25rem .6rem; border-radius:999px; border:1px solid rgba(168,85,247,.35); background:rgba(168,85,247,.12); color:#e9d5ff; cursor:default; }
.hype-chip.cross { border-color:rgba(255,215,0,.45); background:rgba(255,215,0,.1); color:#fde68a; box-shadow:0 0 12px rgba(255,215,0,.12); }
.hype-chip .score { opacity:.65; font-size:.65rem; margin-left:.25rem; }
.intel-page-card { border:1px solid rgba(148,163,184,.16); border-radius:12px; margin-bottom:.85rem; background:rgba(6,10,16,.85); overflow:hidden; }
.intel-page-head { display:flex; flex-wrap:wrap; gap:.5rem; align-items:flex-start; justify-content:space-between; padding:.85rem 1rem; border-bottom:1px solid rgba(148,163,184,.1); cursor:pointer; }
.intel-page-head:hover { background:rgba(0,229,255,.04); }
.intel-page-body { padding:0 1rem 1rem; }
.intel-signals { display:flex; flex-wrap:wrap; gap:.35rem; margin:.5rem 0; }
.signal-chip { font-size:.68rem; padding:.12rem .45rem; border-radius:999px; background:rgba(34,197,94,.12); color:#86efac; border:1px solid rgba(34,197,94,.25); }
.signal-chip.hot { background:rgba(245,158,11,.12); color:#fcd34d; border-color:rgba(245,158,11,.35); }
.intel-read-text { max-height:320px; overflow:auto; white-space:pre-wrap; font-size:.8rem; line-height:1.55; color:#cbd5e1; background:rgba(0,0,0,.35); padding:.85rem; border-radius:8px; border:1px solid rgba(148,163,184,.1); }
.intel-read-text.expanded { max-height:560px; }
.intel-section-title { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:#64748b; margin:1rem 0 .4rem; font-weight:700; }
.intel-trend-table { width:100%; font-size:.82rem; }
.intel-trend-table td, .intel-trend-table th { padding:.35rem .5rem; border-bottom:1px solid rgba(148,163,184,.08); text-align:left; }
.hype-score { font-size:.75rem; color:#f59e0b; font-weight:700; }
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
.browser-live-row {
@@ -77,9 +108,10 @@
<div class="browser-tabs">
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
<button type="button" class="browser-tab" :class="{ active: tab === 'analyse' }" @click="setTab('analyse')">Parse Analyse</button>
</div>
<div class="browser-live-row" style="margin-bottom:1rem">
<div class="browser-live-row" style="margin-bottom:1rem" x-show="tab === 'research'">
<section class="panel browser-live-panel">
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
@@ -187,7 +219,7 @@
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
<button class="btn btn-primary" type="submit">Toevoegen</button>
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
<button type="button" class="btn btn-secondary" @click="crawlAll()" :disabled="crawlBusy" x-text="crawlBusy ? 'Crawlen…' : 'Alles crawlen'"></button>
</form>
</section>
<section class="panel">
@@ -199,7 +231,7 @@
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
<td>{{ s.last_crawled or '—' }}</td>
<td class="btn-group">
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
<button class="btn btn-sm" @click="crawlOne({{ s.id }})" :disabled="crawlBusy">Crawl</button>
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
</td></tr>{% else %}
@@ -211,17 +243,262 @@
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
</section>
<section class="panel"><h2>Crawl logs</h2>
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% else %}<li class="empty-state">Nog geen crawls</li>{% endfor %}</ul>
</section>
</div>
<section class="panel" x-show="parseResults.length" x-cloak>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem">
<div>
<h2 style="margin:0">Parse-overzicht (kort)</h2>
<p class="page-tip" style="margin:.25rem 0 0">Voor volledige analyse → tab <button type="button" class="btn btn-sm btn-pulse" @click="setTab('analyse')">Parse Analyse</button></p>
</div>
<button type="button" class="btn btn-sm" @click="loadParseResults()">↻ Verversen</button>
</div>
<template x-for="p in parseResults" :key="p.id">
<article class="parse-result-card">
<header>
<strong x-text="p.title || p.url"></strong>
<span class="muted" x-text="'#' + (p.site_id||'—') + ' · ' + (p.word_count||0) + ' woorden · ' + (p.links_count||0) + ' links'"></span>
</header>
<p class="parse-excerpt" x-text="p.excerpt || p.meta_description || '—'"></p>
<div class="parse-headings" x-show="(p.headings||[]).length">
<template x-for="h in (p.headings||[]).slice(0,5)" :key="h">
<span class="parse-chip" x-text="h"></span>
</template>
</div>
<ul class="parse-links" x-show="(p.links_sample||[]).length">
<template x-for="lnk in (p.links_sample||[]).slice(0,6)" :key="lnk.href">
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
</template>
</ul>
</article>
</template>
</section>
</div>
<div x-show="tab === 'analyse'" x-cloak class="pi-workbench">
<header class="pi-header">
<div>
<h2>Parse Intelligence Workbench</h2>
<p class="page-tip" style="margin:0.25rem 0 0">Filter · sorteer · klik hype-termen · exporteer — alles wat gecrawld is</p>
</div>
<div class="pi-header-actions">
<button type="button" class="btn btn-sm btn-secondary" @click="loadIntelligence()" :disabled="intelLoading">↻ Ververs</button>
<button type="button" class="btn btn-sm btn-secondary" @click="exportIntelCsv()" x-show="intelFilteredPages.length">⬇ CSV</button>
<button type="button" class="btn btn-sm btn-pulse" @click="crawlAndAnalyse()" :disabled="crawlBusy || intelLoading">Crawl + analyse</button>
</div>
</header>
<div class="pi-kpi-strip" x-show="intel && intel.summary">
<div class="pi-kpi-cell">
<div class="lbl">Gefilterd</div>
<div class="val" x-text="intelFilteredPages.length + '/' + (intel.pages||[]).length">0</div>
<div class="sub">pagina's zichtbaar</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Woorden</div>
<div class="val" x-text="intelFilteredWordSum">0</div>
<div class="sub">in selectie</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Hype-termen</div>
<div class="val" x-text="(intel.hype_terms||[]).length">0</div>
<div class="sub">klik om te filteren</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Gewijzigd 7d</div>
<div class="val" x-text="intel.summary.changes_7d">0</div>
<div class="sub">shift-signalen</div>
</div>
<div class="pi-kpi-cell">
<div class="lbl">Gem. hype</div>
<div class="val" x-text="intelAvgHype">0</div>
<div class="sub">score selectie</div>
</div>
</div>
<div class="pi-loading" x-show="intelLoading">Analyse laden…</div>
<div class="pi-body" :class="{ 'detail-open': intelSelectedId }" x-show="intel && !intelLoading">
<aside class="pi-filters">
<label>Zoek in alle geparste data</label>
<input type="search" class="pi-search" placeholder="halal, curry, bidfood…" x-model="intelSearchLocal" @input.debounce.250ms="" />
<label>Site</label>
<select class="pi-select" x-model="intelSiteId">
<option value="">Alle sites</option>
<template x-for="s in intelSiteOptions" :key="s.id">
<option :value="s.id" x-text="s.name"></option>
</template>
</select>
<label>Min. woorden <span x-text="intelMinWords"></span></label>
<input type="range" class="pi-range" min="0" max="3000" step="50" x-model.number="intelMinWords" />
<label>Min. hype-score <span x-text="intelMinHype"></span></label>
<input type="range" class="pi-range" min="0" max="80" step="5" x-model.number="intelMinHype" />
<label class="pi-toggle-row"><input type="checkbox" x-model="intelChangedOnly" /> Alleen recent gewijzigd</label>
<label>Sorteren</label>
<select class="pi-select" x-model="intelSortCol" @change="">
<option value="hype_score">Hype score</option>
<option value="word_count">Woorden</option>
<option value="crawled_at">Datum crawl</option>
<option value="site_name">Site</option>
<option value="title">Titel</option>
<option value="links_count">Links</option>
</select>
<select class="pi-select" style="margin-top:0.35rem" x-model="intelSortDir">
<option value="desc">Aflopend ↓</option>
<option value="asc">Oplopend ↑</option>
</select>
<div class="pi-filter-chips" x-show="intelActiveTerms.length || intelChangedOnly || intelMinWords > 0">
<template x-for="t in intelActiveTerms" :key="t">
<span class="pi-filter-chip">term: <span x-text="t"></span><button type="button" @click="removeIntelTerm(t)">×</button></span>
</template>
<span class="pi-filter-chip" x-show="intelChangedOnly">gewijzigd<button type="button" @click="intelChangedOnly=false">×</button></span>
<button type="button" class="btn btn-sm" style="margin-top:0.35rem;width:100%" @click="clearIntelFilters()">Filters wissen</button>
</div>
</aside>
<div class="pi-main">
<div class="pi-trends-bar" x-show="(intel.hype_terms||[]).length">
<div class="lbl">Hype-termen — klik om te filteren (goud = cross-site)</div>
<div class="pi-hype-cloud">
<template x-for="t in (intel.hype_terms||[]).slice(0,35)" :key="t.term">
<span class="pi-hype-chip" :class="{ cross: t.cross_site, active: isIntelTermActive(t.term) }"
@click="toggleIntelTerm(t.term)" :title="'Sites: ' + (t.sites||[]).join(', ')">
<span x-text="t.term"></span><span class="sc" x-text="t.score"></span>
</span>
</template>
</div>
</div>
<div class="pi-toolbar">
<span class="count"><strong x-text="intelFilteredPages.length"></strong> resultaten</span>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'hype_score' }" @click="sortIntelBy('hype_score')">Hype</button>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'word_count' }" @click="sortIntelBy('word_count')">Woorden</button>
<button type="button" class="pi-sort-btn" :class="{ active: intelSortCol === 'crawled_at' }" @click="sortIntelBy('crawled_at')">Datum</button>
<div class="pi-view-toggle">
<button type="button" :class="{ active: intelView === 'table' }" @click="intelView='table'">Tabel</button>
<button type="button" :class="{ active: intelView === 'cards' }" @click="intelView='cards'">Kaarten</button>
</div>
</div>
<div class="pi-empty" x-show="!intelFilteredPages.length">
<strong>Geen matches</strong>
Pas filters aan, wis hype-termen, of crawl eerst onder Site Monitor.
</div>
<div class="pi-table-wrap" x-show="intelView === 'table' && intelFilteredPages.length">
<table class="pi-table">
<thead>
<tr>
<th @click="sortIntelBy('site_name')">Site <span class="arrow" x-show="intelSortCol==='site_name'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('title')">Titel <span class="arrow" x-show="intelSortCol==='title'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('word_count')" class="num">Woorden <span class="arrow" x-show="intelSortCol==='word_count'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('hype_score')" class="num">Hype <span class="arrow" x-show="intelSortCol==='hype_score'" x-text="intelSortDir==='asc'?'↑':'↓'"></span></th>
<th @click="sortIntelBy('links_count')" class="num">Links</th>
<th>Signalen</th>
</tr>
</thead>
<tbody>
<template x-for="p in intelFilteredPages" :key="p.id">
<tr :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
<td class="site-cell" x-text="p.site_name"></td>
<td class="title-cell" :title="p.title || p.url" x-text="p.title || p.url"></td>
<td class="num" x-text="p.word_count"></td>
<td class="num"><span class="pi-badge hype-high" x-text="p.hype_score"></span></td>
<td class="num" x-text="p.links_count"></td>
<td>
<span class="pi-badge changed" x-show="p.recently_changed">Δ</span>
<span class="muted" style="font-size:.7rem" x-text="(p.signals||[])[0]||''"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pi-cards" x-show="intelView === 'cards' && intelFilteredPages.length">
<template x-for="p in intelFilteredPages" :key="'c-'+p.id">
<article class="pi-card" :class="{ selected: intelSelectedId === p.id }" @click="selectIntelPage(p.id)">
<div class="site-cell" x-text="p.site_name"></div>
<strong style="font-size:.85rem;display:block;margin:.25rem 0" x-text="p.title||p.url"></strong>
<div class="muted" style="font-size:.72rem" x-text="(p.word_count||0)+' wo · hype '+ (p.hype_score||0)"></div>
<p style="font-size:.75rem;color:#94a3b8;margin:.4rem 0 0;line-height:1.4" x-text="(p.excerpt||'').slice(0,120)+'…'"></p>
</article>
</template>
</div>
</div>
<aside class="pi-detail">
<template x-if="!intelSelectedPage">
<div class="pi-detail-empty">← Klik een rij om de volledige parse te lezen<br><small>Highlight op actieve zoektermen</small></div>
</template>
<template x-if="intelSelectedPage">
<div style="display:flex;flex-direction:column;height:100%">
<header class="pi-detail-head">
<h3 x-text="intelSelectedPage.site_name + ' — ' + (intelSelectedPage.title || 'Pagina')"></h3>
<div class="pi-detail-meta">
<span x-text="(intelSelectedPage.word_count||0) + ' woorden'"></span> ·
<span x-text="'hype ' + (intelSelectedPage.hype_score||0)"></span> ·
<span x-text="(intelSelectedPage.crawled_at||'').substring(0,16).replace('T',' ')"></span>
</div>
<div class="intel-signals" style="margin-top:.5rem">
<template x-for="sig in (intelSelectedPage.signals||[])" :key="sig">
<span class="signal-chip" :class="{ hot: sig.indexOf('gewijzigd')>=0 }" x-text="sig"></span>
</template>
</div>
<div style="margin-top:.5rem;display:flex;gap:.4rem;flex-wrap:wrap">
<a class="btn btn-sm btn-secondary" :href="intelSelectedPage.final_url || intelSelectedPage.url" target="_blank" rel="noopener">Live URL ↗</a>
<button type="button" class="btn btn-sm" @click="loadFullPage(intelSelectedPage.id)">Volledige tekst</button>
</div>
</header>
<div class="pi-detail-tabs">
<button type="button" :class="{ active: intelDetailTab === 'text' }" @click="intelDetailTab='text'">Tekst</button>
<button type="button" :class="{ active: intelDetailTab === 'headings' }" @click="intelDetailTab='headings'">Koppen</button>
<button type="button" :class="{ active: intelDetailTab === 'links' }" @click="intelDetailTab='links'">Links</button>
</div>
<div class="pi-detail-body">
<div x-show="intelDetailTab === 'text'" class="pi-read-text" x-html="intelDetailHtml"></div>
<div x-show="intelDetailTab === 'headings'" class="pi-heading-list">
<template x-for="h in (intelSelectedPage.headings||[])" :key="h">
<div class="pi-heading-item" x-text="h"></div>
</template>
<p class="muted" x-show="!(intelSelectedPage.headings||[]).length">Geen koppen gevonden</p>
</div>
<ul x-show="intelDetailTab === 'links'" class="pi-link-list">
<template x-for="lnk in (intelSelectedPage.links_sample||[])" :key="lnk.href">
<li><a :href="lnk.href" target="_blank" rel="noopener" x-text="lnk.label || lnk.href"></a></li>
</template>
</ul>
</div>
</div>
</template>
</aside>
</div>
<div class="pi-empty" x-show="!intel && !intelLoading">
<strong>Nog geen data</strong>
Voeg sites toe onder Site Monitor, crawl, en kom terug — of klik Crawl + analyse.
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/parse-intel.js?v=1"></script>
<script>
function browserPage() {
return {
tab: window.location.hash === '#monitor' ? 'monitor' : 'research',
tab: (function () {
const h = window.location.hash;
if (h === '#monitor') return 'monitor';
if (h === '#analyse') return 'analyse';
return 'research';
})(),
url: {{ default_url | tojson }},
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
scrollPages: 6,
@@ -240,6 +517,78 @@ function browserPage() {
session: null,
recent: [],
monitorForm: { url: '', name: '' },
parseResults: [],
crawlBusy: false,
intel: null,
intelLoading: false,
intelSearchLocal: '',
intelSiteId: '',
intelSortCol: 'hype_score',
intelSortDir: 'desc',
intelView: 'table',
intelMinWords: 0,
intelMinHype: 0,
intelChangedOnly: false,
intelActiveTerms: [],
intelSelectedId: null,
intelDetailTab: 'text',
intelFullPage: null,
fullPageText: '',
get intelFilteredPages() {
if (!this.intel || !window.ParseIntel) return [];
const pages = ParseIntel.filterPages(this.intel.pages || [], {
search: this.intelSearchLocal,
terms: this.intelActiveTerms,
siteId: this.intelSiteId,
minWords: this.intelMinWords,
minHype: this.intelMinHype,
changedOnly: this.intelChangedOnly,
});
return ParseIntel.sortPages(pages, this.intelSortCol, this.intelSortDir);
},
get intelSelectedPage() {
const id = this.intelSelectedId;
if (!id) return null;
const all = this.intel && this.intel.pages ? this.intel.pages : [];
let p = all.find(function (x) { return x.id === id; });
if (p && this.intelFullPage === id && this.fullPageText) {
p = Object.assign({}, p, { content_read: this.fullPageText });
}
return p || null;
},
get intelDetailHtml() {
const p = this.intelSelectedPage;
if (!p || !window.ParseIntel) return '';
const text = (this.intelFullPage === p.id && this.fullPageText) ? this.fullPageText : (p.content_read || p.excerpt || '');
const terms = (this.intelActiveTerms || []).slice();
if ((this.intelSearchLocal || '').trim()) terms.push(this.intelSearchLocal.trim());
return ParseIntel.highlightHtml(text, terms);
},
get intelFilteredWordSum() {
return (this.intelFilteredPages || []).reduce(function (s, p) { return s + (p.word_count || 0); }, 0);
},
get intelAvgHype() {
const rows = this.intelFilteredPages || [];
if (!rows.length) return 0;
const sum = rows.reduce(function (s, p) { return s + (p.hype_score || 0); }, 0);
return Math.round(sum / rows.length);
},
get intelSiteOptions() {
const seen = {};
const out = [];
(this.intel && this.intel.pages ? this.intel.pages : []).forEach(function (p) {
if (!p.site_id || seen[p.site_id]) return;
seen[p.site_id] = true;
out.push({ id: String(p.site_id), name: p.site_name || ('Site ' + p.site_id) });
});
return out.sort(function (a, b) { return a.name.localeCompare(b.name); });
},
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
statusText: 'Klaar — voer een URL in of klik Browse nu.',
pollTimer: null,
@@ -257,13 +606,22 @@ function browserPage() {
setTab(t) {
this.tab = t;
history.replaceState(null, '', t === 'monitor' ? '/browser#monitor' : '/browser');
const hash = t === 'monitor' ? '#monitor' : (t === 'analyse' ? '#analyse' : '');
history.replaceState(null, '', hash ? '/browser' + hash : '/browser');
if (t === 'monitor') this.loadParseResults();
if (t === 'analyse') this.loadIntelligence();
},
init() {
if (window.location.hash === '#monitor') this.tab = 'monitor';
if (window.location.hash === '#analyse') this.tab = 'analyse';
this.$watch('tab', (t) => {
if (t === 'monitor') this.loadParseResults();
if (t === 'analyse') this.loadIntelligence();
});
window.addEventListener('hashchange', () => {
this.tab = window.location.hash === '#monitor' ? 'monitor' : 'research';
const h = window.location.hash;
this.tab = h === '#monitor' ? 'monitor' : (h === '#analyse' ? 'analyse' : 'research');
});
this.loadRecent();
this.startPoll();
@@ -475,7 +833,7 @@ function browserPage() {
method: 'POST',
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Site toegevoegd — tab Monitor', 'success');
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
this.newSiteUrl = '';
this.newSiteName = '';
this.setTab('monitor');
@@ -534,17 +892,119 @@ function browserPage() {
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
try {
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
Cockpit.toast('Site toegevoegd', 'success');
Cockpit.toast('Site toegevoegd — live in Agents → Terminals (Browser)', 'success');
this.reloadPage();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async loadParseResults() {
try {
const r = await Cockpit.api('/monitor/parse-results?limit=12');
this.parseResults = r.items || [];
} catch (e) { /* ignore */ }
},
async loadIntelligence() {
this.intelLoading = true;
try {
this.intel = await Cockpit.api('/monitor/intelligence?limit=100');
if (this.intelSelectedId) {
const still = (this.intel.pages || []).some(function (p) { return p.id === this.intelSelectedId; }.bind(this));
if (!still) {
this.intelSelectedId = null;
this.intelFullPage = null;
this.fullPageText = '';
}
}
} catch (e) {
Cockpit.toast(e.message || 'Analyse laden mislukt', 'error');
} finally {
this.intelLoading = false;
}
},
sortIntelBy(col) {
if (this.intelSortCol === col) {
this.intelSortDir = this.intelSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.intelSortCol = col;
this.intelSortDir = col === 'title' || col === 'site_name' ? 'asc' : 'desc';
}
},
toggleIntelTerm(term) {
term = String(term || '').toLowerCase();
if (!term) return;
const idx = this.intelActiveTerms.indexOf(term);
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
else this.intelActiveTerms.push(term);
},
isIntelTermActive(term) {
return this.intelActiveTerms.indexOf(String(term).toLowerCase()) >= 0;
},
removeIntelTerm(term) {
const idx = this.intelActiveTerms.indexOf(term);
if (idx >= 0) this.intelActiveTerms.splice(idx, 1);
},
clearIntelFilters() {
this.intelSearchLocal = '';
this.intelSiteId = '';
this.intelMinWords = 0;
this.intelMinHype = 0;
this.intelChangedOnly = false;
this.intelActiveTerms = [];
},
selectIntelPage(id) {
this.intelSelectedId = id;
this.intelDetailTab = 'text';
this.intelFullPage = null;
this.fullPageText = '';
},
exportIntelCsv() {
if (!window.ParseIntel || !this.intelFilteredPages.length) return;
ParseIntel.downloadCsv(this.intelFilteredPages, 'foodlinkk-parse-' + new Date().toISOString().slice(0, 10) + '.csv');
Cockpit.toast('CSV gedownload', 'success');
},
async crawlAndAnalyse() {
await this.crawlAll();
const self = this;
setTimeout(function () { self.loadIntelligence(); }, 2800);
},
async loadFullPage(id) {
try {
const r = await Cockpit.api('/monitor/parse-results/' + id);
this.intelFullPage = id;
this.fullPageText = (r.page && (r.page.content_full || r.page.content_read)) || '';
Cockpit.toast('Volledige tekst geladen', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
formatCrawlToast(result) {
const rows = result.results || [];
if (!rows.length) return result.message || 'Geen sites om te crawlen';
return rows.map(function (r) {
if (r.status === 'ERROR') return '✗ ' + (r.name || r.url) + ': ' + (r.error || 'fout');
return (r.changed ? '△ ' : '✓ ') + (r.title || r.name) + ' — ' + (r.word_count || 0) + ' woorden';
}).join('\n');
},
async crawlAll() {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
if (this.crawlBusy) return;
this.crawlBusy = true;
try {
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
await this.loadParseResults();
setTimeout(() => this.reloadPage(), 1500);
} catch (e) { Cockpit.toast(e.message, 'error'); }
finally { this.crawlBusy = false; }
},
async crawlOne(id) {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
if (this.crawlBusy) return;
this.crawlBusy = true;
try {
const r = await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
Cockpit.toast(this.formatCrawlToast(r), r.errors ? 'warn' : 'success');
await this.loadParseResults();
setTimeout(() => this.reloadPage(), 1500);
} catch (e) { Cockpit.toast(e.message, 'error'); }
finally { this.crawlBusy = false; }
},
async toggleSite(id) {
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
+471 -50
View File
@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
<link rel="stylesheet" href="/static/css/clients-crm.css?v=5" />
{% endblock %}
{% block content %}
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('clients.title')">CRM · Klanten</h1>
<p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
<h1>CRM · Klanten</h1>
<p class="subtitle page-tip">Klanten beheren · pipeline · supermarkten · 360° intelligence</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
@@ -24,15 +24,26 @@
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'" x-text="$t('clients.tab_kanban')">Kanban</button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('clients.tab_list')">Lijst</button>
<button class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; searchStores()" x-text="$t('clients.tab_stores')">Supermarkten</button>
<nav class="clients-tabs" aria-label="Klanten weergaven">
<div class="clients-tab-bar">
<button type="button" class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'">
<span class="clients-tab-icon" aria-hidden="true">▦</span> Kanban
</button>
<button type="button" class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">
<span class="clients-tab-icon" aria-hidden="true">☰</span> Lijst
</button>
<button type="button" class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; if(!storeResults.length) searchStores(true)">
<span class="clients-tab-icon" aria-hidden="true">🛒</span> Supermarkten
</button>
<button type="button" class="clients-tab" :class="{active: tab==='360'}" @click="open360Tab()">
<span class="clients-tab-icon" aria-hidden="true">◎</span> 360° Klant
</button>
</div>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openClientModal()" x-text="'+ ' + $t('clients.new')">+ Nieuwe klant</button>
<button class="btn btn-primary btn-sm" @click="openClientModal()">+ Nieuwe klant</button>
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
</div>
</div>
</nav>
<!-- Kanban -->
<div x-show="tab==='kanban'" x-cloak>
@@ -52,6 +63,11 @@
<span class="clients-tag mrr" x-show="c.mrr_estimate" x-text="'€'+Number(c.mrr_estimate).toLocaleString('nl-NL')+'/mnd'"></span>
<span class="clients-tag" x-show="c.store_count" x-text="c.store_count+' filialen'"></span>
</div>
<select class="clients-card-stage" x-model="c.stage" @click.stop @focus="stageSelectFocus(c)" @change.stop="quickStage(c)" :title="'Status wijzigen voor '+c.name">
<template x-for="st in stages" :key="'kb'+c.id+st.id">
<option :value="st.id" x-text="st.label"></option>
</template>
</select>
</div>
</template>
</div>
@@ -83,7 +99,13 @@
<td x-text="c.contact||'—'"></td>
<td x-text="c.email||'—'"></td>
<td x-text="c.sector||'—'"></td>
<td><span class="badge" x-text="stageLabel(c.stage)"></span></td>
<td @click.stop>
<select class="clients-inline-stage" x-model="c.stage" @focus="stageSelectFocus(c)" @change="quickStage(c)">
<template x-for="st in stages" :key="'ls'+c.id+st.id">
<option :value="st.id" x-text="st.label"></option>
</template>
</select>
</td>
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
<td x-text="c.store_count||0"></td>
<td>
@@ -99,35 +121,120 @@
<!-- Supermarkten -->
<div x-show="tab==='stores'" x-cloak>
<p class="page-tip" style="margin-bottom:0.75rem">Zoek een filiaal, vul ontbrekende data aan (contact, halal, manager) en koppel aan een klant.</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek filiaal, keten, stad…" x-model="storeQ" @keyup.enter="searchStores()" />
<select x-model="storeChain" @change="searchStores()">
<option value="">Alle ketens</option>
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
</select>
<select x-model="storeProvince" @change="searchStores()">
<option value="">Alle provincies</option>
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
</select>
<button class="btn btn-sm btn-pulse" @click="searchStores()" :disabled="storeBusy">Zoeken</button>
</div>
<div class="store-search-panel">
<div>
<div class="store-list">
<template x-for="s in storeResults" :key="s.id">
<div class="store-list-item" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="(s.city||'') + (s.province ? ', '+s.province : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag" x-show="s.partnership_status && s.partnership_status!=='none'" x-text="s.partnership_status"></span>
<span class="clients-tag" x-show="s.halal_certified">Halal ✓</span>
</div>
</div>
</template>
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
<p class="page-tip" style="margin-bottom:0.75rem">Filter filialen, selecteer een rij en vul data aan of koppel aan een klant.</p>
<div class="store-filters-card">
<div class="store-filters-grid">
<label class="store-filter-field store-filter-search">
<span class="filter-label">Zoeken</span>
<input type="search" class="form-input" placeholder="Naam, adres, stad…" x-model="storeQ" @keydown.enter="searchStores(true)" />
</label>
<label class="store-filter-field">
<span class="filter-label">Keten</span>
<select class="form-input" x-model="storeChain" @change="searchStores(true)">
<option value="">Alle ketens</option>
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
</select>
</label>
<label class="store-filter-field">
<span class="filter-label">Provincie</span>
<select class="form-input" x-model="storeProvince" @change="searchStores(true)">
<option value="">Alle provincies</option>
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
</select>
</label>
<label class="store-filter-field">
<span class="filter-label">Stad</span>
<input type="text" class="form-input" placeholder="bv. Amsterdam" x-model="storeCity" @keydown.enter="searchStores(true)" />
</label>
<label class="store-filter-field">
<span class="filter-label">Partnership</span>
<select class="form-input" x-model="storePartnership" @change="searchStores(true)">
<option value="">Alle statussen</option>
<template x-for="p in storePartnerships" :key="p.partnership_status">
<option :value="p.partnership_status" x-text="(p.partnership_status||'none')+' ('+p.n+')'"></option>
</template>
</select>
</label>
<label class="store-filter-field">
<span class="filter-label">Sorteren</span>
<select class="form-input" x-model="storeSort" @change="searchStores(true)">
<option value="name">Naam A-Z</option>
<option value="chain">Keten + naam</option>
<option value="halal_opportunity">Halal kans ↓</option>
<option value="population">Bevolking ↓</option>
</select>
</label>
</div>
<details class="store-filters-extra">
<summary>Extra filters</summary>
<div class="store-filter-checks">
<label><input type="checkbox" x-model="storeHalalGap" @change="searchStores(true)" /> Halal-gap (geen cert + geen schap)</label>
<label><input type="checkbox" x-model="storeHalalCert" @change="searchStores(true)" /> Halal gecertificeerd</label>
<label><input type="checkbox" x-model="storeHalalSection" @change="searchStores(true)" /> Halal schap</label>
<label><input type="checkbox" x-model="storeLinkedCrm" @change="searchStores(true)" /> Gekoppeld aan CRM</label>
<label><input type="checkbox" x-model="storeHasPhone" @change="searchStores(true)" /> Heeft telefoon</label>
<label><input type="checkbox" x-model="storeHasEmail" @change="searchStores(true)" /> Heeft e-mail</label>
<label><input type="checkbox" x-model="storeHasManager" @change="searchStores(true)" /> Heeft manager</label>
</div>
<div class="store-filters-grid" style="margin-top:0.65rem">
<label class="store-filter-field">
<span class="filter-label">Min. halal-kans (0-100)</span>
<input type="number" min="0" max="100" class="form-input" x-model.number="storeMinHalalOpp" @change="searchStores(true)" placeholder="bv. 35" />
</label>
<label class="store-filter-field">
<span class="filter-label">Postcode (prefix)</span>
<input type="text" class="form-input" x-model="storePostcode" @keydown.enter="searchStores(true)" placeholder="bv. 1012" />
</label>
</div>
</details>
<div class="store-filters-actions">
<button class="btn btn-sm btn-pulse" @click="searchStores(true)" :disabled="storeBusy">Zoeken</button>
<button class="btn btn-sm btn-secondary" type="button" @click="resetStoreFilters()" :disabled="storeBusy">Filters wissen</button>
<span class="store-results-meta" x-show="storeTotal > 0" x-text="storeResultLabel()"></span>
</div>
</div>
<div class="store-search-panel">
<div class="store-results-col">
<div class="store-table-wrap">
<table class="data-table store-results-table" x-show="storeResults.length">
<thead>
<tr>
<th>Keten</th>
<th>Filiaal</th>
<th>Stad</th>
<th>Status</th>
<th>Halal</th>
</tr>
</thead>
<tbody>
<template x-for="s in storeResults" :key="s.id">
<tr class="store-table-row" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
<td x-text="s.chain||'—'"></td>
<td><strong x-text="s.name||'—'"></strong></td>
<td x-text="(s.city||'—') + (s.province ? ', '+s.province : '')"></td>
<td><span class="clients-tag" x-text="s.partnership_status||'none'"></span></td>
<td>
<span class="clients-tag" x-show="s.halal_certified">Cert</span>
<span class="clients-tag" x-show="s.has_halal_section && !s.halal_certified">Schap</span>
<span class="muted" x-show="!s.halal_certified && !s.has_halal_section">—</span>
</td>
</tr>
</template>
</tbody>
</table>
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen filialen — pas filters aan en klik Zoeken.</p>
<p class="clients-empty" x-show="storeBusy">Zoeken…</p>
</div>
<div class="store-pagination" x-show="storeTotal > storePageSize">
<button class="btn btn-sm btn-secondary" type="button" @click="storePrevPage()" :disabled="storePage <= 1 || storeBusy">← Vorige</button>
<span class="muted" x-text="'Pagina '+storePage+' / '+storePageCount()"></span>
<button class="btn btn-sm btn-secondary" type="button" @click="storeNextPage()" :disabled="storePage >= storePageCount() || storeBusy">Volgende →</button>
</div>
<p class="muted" style="font-size:0.72rem;margin-top:0.35rem" x-text="storeResults.length+' filialen · max 80'"></p>
</div>
<div class="store-edit-panel" x-show="selectedStore">
<h3 x-text="(selectedStore?.chain||'') + ' · ' + (selectedStore?.name||'')"></h3>
@@ -188,6 +295,99 @@
</div>
</div>
<!-- 360° Klant Intelligence -->
<div x-show="tab==='360'" x-cloak class="crm-360-shell">
<div class="crm-360-head">
<div>
<h2>360° Klant Intelligence</h2>
<p class="page-tip">NAS-documenten · sentiment · retail · deals · second brain · <span x-text="autoSyncLabel"></span></p>
</div>
<div class="crm-360-actions">
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
<button class="btn btn-primary btn-sm" @click="linkNasFile()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel pad</button>
<button class="btn btn-secondary btn-sm" @click="linkNasFolder()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel map</button>
<a class="btn btn-sm" href="/documents" target="_blank">📂 Documenten</a>
</div>
</div>
<div class="a360-toolbar">
<select class="form-input" x-model.number="view360ClientId" @change="loadClient360()">
<option value="">— Klant —</option>
<template x-for="c in clients" :key="'v360c'+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="'v360p'+p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<input class="form-input" placeholder="NAS pad (bv. Telegram/rapport.pdf of CUCINA/…)" x-model="linkPathInput" />
</div>
<div class="a360-grid" x-show="view360ClientId">
<div class="a360-kpis">
<div class="crm-360-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
<div class="crm-360-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
<div class="crm-360-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
<div class="crm-360-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
</div>
<div class="a360-panel">
<p class="crm-360-section">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="clients-empty" x-show="!(client360.links||[]).length">Nog geen koppelingen — voeg een NAS-pad toe of open Documenten Workbench</p>
</div>
</div>
<div class="a360-panel">
<p class="crm-360-section">Sentiment &amp; trends</p>
<div class="a360-sentiment">
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
</template>
</div>
<div class="a360-trends">
<template x-for="t in client360.monthly_trends || []" :key="t.month">
<div class="a360-trend-row">
<span x-text="(t.month||'').slice(0,7)"></span>
<span x-text="t.docs + ' docs'"></span>
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
</div>
</template>
</div>
</div>
<div class="a360-panel">
<p class="crm-360-section">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="crm-360-section">Retail &amp; deals</p>
<template x-for="s in client360.stores || []" :key="'st'+s.id">
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
</template>
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
</template>
</div>
</div>
<p class="clients-empty" x-show="!view360ClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
</div>
<!-- Client modal -->
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
<div class="modal-card" style="max-width:560px">
@@ -270,6 +470,7 @@
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
<button class="btn btn-sm btn-pulse-green" @click="openClient360(detail.client)">360° view →</button>
<select class="form-input" style="width:auto" x-model="detail.client.stage" @change="quickStage(detail.client)">
<template x-for="st in stages" :key="'ds'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
@@ -338,10 +539,34 @@ function clientsHub() {
storeQ: '',
storeChain: '',
storeProvince: '',
storeCity: '',
storePartnership: '',
storeSort: 'name',
storePostcode: '',
storeMinHalalOpp: null,
storeHalalGap: false,
storeHalalCert: false,
storeHalalSection: false,
storeLinkedCrm: false,
storeHasPhone: false,
storeHasEmail: false,
storeHasManager: false,
storePage: 1,
storePageSize: 25,
storeTotal: 0,
chains: [],
provinces: [],
storePartnerships: [],
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
view360ClientId: '',
linkProjectId: '',
linkPathInput: '',
linkProjects: [],
client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
autoSyncBusy: false,
autoSyncLabel: 'Second brain: —',
fmtEur(v) {
if (v == null || v === '') return '—';
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
@@ -352,7 +577,17 @@ function clientsHub() {
stageCount(id) { return this.clients.filter(c => c.stage === id).length; },
clientsByStage(id) { return this.clients.filter(c => c.stage === id); },
async init() { await this.loadAll(); },
async init() {
const qs = new URLSearchParams(location.search);
const tab = qs.get('tab');
const clientId = qs.get('client');
await this.loadAll();
if (tab === '360' || tab === 'view360') {
this.tab = '360';
if (clientId) this.view360ClientId = parseInt(clientId, 10) || '';
await this.on360Tab();
}
},
async loadAll() {
this.busy = true;
@@ -366,6 +601,7 @@ function clientsHub() {
this.clients = clientsR.items || [];
this.chains = (filtersR.chains || []).map(x => x.chain || x).filter(Boolean);
this.provinces = (filtersR.provinces || []).map(x => x.province || x).filter(Boolean);
this.storePartnerships = filtersR.partnerships || [];
this.applyFilter();
} catch (e) {
Cockpit.toast(e.message, 'error');
@@ -439,34 +675,219 @@ function clientsHub() {
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
openClient360(c) {
if (!c || !c.id) return;
this.view360ClientId = c.id;
this.tab = '360';
this.drawer = false;
this.on360Tab();
},
async open360Tab() {
this.tab = '360';
await this.on360Tab();
},
async on360Tab() {
if (this.view360ClientId) await this.loadClient360();
await this.refreshAutoSyncStatus();
},
stat360(key) {
const stats = this.client360 && this.client360.stats;
if (!stats || stats[key] == null) return 0;
return stats[key];
},
linkProjectsForClient() {
if (!this.view360ClientId) return [];
return (this.client360.projects || []).length
? this.client360.projects
: this.linkProjects.filter((p) => p.client_id === Number(this.view360ClientId));
},
async loadClient360() {
if (!this.view360ClientId) return;
try {
const data = await Cockpit.api('/clients/' + this.view360ClientId + '/360');
if (!data.ok) throw new Error(data.error || '360 laden mislukt');
this.client360 = data;
this.linkProjects = data.projects || [];
} catch (e) {
Cockpit.toast('360: ' + (e.message || e), 'error');
}
},
async linkNasFile() {
const path = (this.linkPathInput || '').trim();
if (!path || !this.view360ClientId) return;
try {
await Cockpit.api('/documents/links', {
method: 'POST',
body: JSON.stringify({
storage_path: path,
client_id: Number(this.view360ClientId),
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
is_folder: false,
}),
});
Cockpit.toast('Bestand gekoppeld', 'success');
await this.loadClient360();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async linkNasFolder() {
let path = (this.linkPathInput || '').trim();
if (!path || !this.view360ClientId) return;
if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
try {
await Cockpit.api('/documents/links', {
method: 'POST',
body: JSON.stringify({
storage_path: path,
client_id: Number(this.view360ClientId),
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
is_folder: true,
}),
});
Cockpit.toast('Map gekoppeld: ' + path, 'success');
await this.loadClient360();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async unlinkDocument(linkId) {
try {
await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
await this.loadClient360();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async runAutoSync(force) {
this.autoSyncBusy = true;
try {
const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
const brain = (data.steps || {}).brain_sync || {};
Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
await this.refreshAutoSyncStatus();
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.autoSyncBusy = false;
}
},
async refreshAutoSyncStatus() {
try {
const data = await Cockpit.api('/brain/auto-sync/status');
const last = data.last;
if (last && last.created_at) {
this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
}
} catch (e) {}
},
async quickStage(c) {
if (!c || !c.id) return;
const prev = c._prevStage;
const body = {
name: c.name, contact: c.contact, email: c.email, stage: c.stage,
sector: c.sector, mrr_estimate: c.mrr_estimate, notes: c.notes,
};
try {
await Cockpit.api('/clients/' + c.id, { method: 'PUT', body: JSON.stringify(body) });
Cockpit.toast('Stage bijgewerkt', 'success');
Cockpit.toast((c.name || 'Klant') + ' → ' + this.stageLabel(c.stage), 'success');
if (this.detail?.client?.id === c.id) this.detail.client.stage = c.stage;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
} catch (e) {
if (prev) c.stage = prev;
Cockpit.toast(e.message, 'error');
}
},
async searchStores() {
stageSelectFocus(c) {
c._prevStage = c.stage;
},
async searchStores(resetPage) {
if (resetPage) this.storePage = 1;
this.storeBusy = true;
try {
const params = new URLSearchParams({ limit: '80', sort: 'name' });
if (this.storeQ) params.set('q', this.storeQ);
if (this.storeChain) params.set('chain', this.storeChain);
if (this.storeProvince) params.set('province', this.storeProvince);
const params = this.buildStoreParams();
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
this.storeResults = r.items || [];
if (this.storeResults.length && !this.selectedStore) {
await this.selectStore(this.storeResults[0].id);
this.storeTotal = r.total != null ? r.total : (r.count || this.storeResults.length);
if (this.selectedStore && !this.storeResults.find(s => s.id === this.selectedStore.id)) {
this.selectedStore = null;
}
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
buildStoreParams() {
const params = new URLSearchParams({
limit: String(this.storePageSize),
offset: String((this.storePage - 1) * this.storePageSize),
sort: this.storeSort || 'name',
});
if (this.storeQ) params.set('q', this.storeQ);
if (this.storeChain) params.set('chain', this.storeChain);
if (this.storeProvince) params.set('province', this.storeProvince);
if (this.storeCity) params.set('city', this.storeCity);
if (this.storePartnership) params.set('partnership', this.storePartnership);
if (this.storePostcode) params.set('postcode_prefix', this.storePostcode);
if (this.storeMinHalalOpp != null && this.storeMinHalalOpp !== '') params.set('min_halal_opportunity', String(this.storeMinHalalOpp));
if (this.storeHalalGap) params.set('halal_gap_only', 'true');
if (this.storeHalalCert) params.set('halal_certified', 'true');
if (this.storeHalalSection) params.set('has_halal_section', 'true');
if (this.storeLinkedCrm) params.set('linked_to_crm', 'true');
if (this.storeHasPhone) params.set('has_phone', 'true');
if (this.storeHasEmail) params.set('has_email', 'true');
if (this.storeHasManager) params.set('has_manager', 'true');
return params;
},
resetStoreFilters() {
this.storeQ = '';
this.storeChain = '';
this.storeProvince = '';
this.storeCity = '';
this.storePartnership = '';
this.storeSort = 'name';
this.storePostcode = '';
this.storeMinHalalOpp = null;
this.storeHalalGap = false;
this.storeHalalCert = false;
this.storeHalalSection = false;
this.storeLinkedCrm = false;
this.storeHasPhone = false;
this.storeHasEmail = false;
this.storeHasManager = false;
this.storePage = 1;
this.searchStores(false);
},
storePageCount() {
return Math.max(1, Math.ceil((this.storeTotal || 0) / this.storePageSize));
},
storeResultLabel() {
const from = this.storeTotal ? (this.storePage - 1) * this.storePageSize + 1 : 0;
const to = Math.min(this.storePage * this.storePageSize, this.storeTotal);
return from + '–' + to + ' van ' + this.storeTotal + ' filialen';
},
storePrevPage() {
if (this.storePage <= 1) return;
this.storePage -= 1;
this.searchStores(false);
},
storeNextPage() {
if (this.storePage >= this.storePageCount()) return;
this.storePage += 1;
this.searchStores(false);
},
async selectStore(id) {
try {
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
@@ -493,7 +914,7 @@ function clientsHub() {
}).then(r => { if (!r.ok) throw new Error('Opslaan mislukt'); return r.json(); });
Cockpit.toast('Filiaaldata opgeslagen', 'success');
await this.selectStore(this.selectedStore.id);
await this.searchStores();
await this.searchStores(false);
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
@@ -530,7 +951,7 @@ function clientsHub() {
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
Cockpit.toast('Filiaal toegevoegd', 'success');
this.storeCreateModal = false;
await this.searchStores();
await this.searchStores(false);
if (r.item?.id) await this.selectStore(r.item.id);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo" x-data="ctoDashboard()" x-init="init()">
<div class="herman-hero">
<h1>CTO Dashboard <span class="live-badge">Live</span></h1>
<p class="hero-sub">Platform · agents · infra · DevOps · goedkeuringen</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/agents?tab=approvals" class="beurs-mini-chip">✓ Goedkeuringen</a>
<a href="/browser" class="beurs-mini-chip">🌐 Browser agent</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="beurs-mini-chip">📈 Grafana</a>
</div>
</div>
<section class="panel herman-briefing">
<header class="herman-briefing-header">
<div>
<h2>Platform status</h2>
<div class="briefing-meta" x-text="opsMeta">Laden…</div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
<a href="/ops" class="btn btn-pulse-green">Volledige IT Ops →</a>
</div>
</header>
<div class="hm-neo-kpi-row" style="margin-bottom:1.25rem">
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Health</div>
<div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Online nodes</div>
<div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Services</div>
<div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Open goedkeuringen</div>
<div class="hm-neo-kpi-value" x-text="pendingApprovals"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Agent events (24u)</div>
<div class="hm-neo-kpi-value" x-text="agentEvents24h"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-label">Browser sessies (24u)</div>
<div class="hm-neo-kpi-value">{{ kpis.browser_sessions_24h }}</div>
</div>
</div>
<div class="hm-dash-two-col">
<section class="hm-dash-section" data-widget="approvals">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen</h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>.</p>
<div id="cto-approvals-list"></div>
</div>
</section>
<section class="hm-dash-section">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Maintenance</h3>
<template x-if="!maintenance.length">
<p class="empty-state">Geen open maintenance items 🎉</p>
</template>
<div class="ops-maint-list">
<template x-for="n in maintenance.slice(0, 6)" :key="n.id">
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
<strong x-text="n.title"></strong>
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
</div>
</template>
</div>
<p class="page-tip" style="margin-top:0.75rem"><a href="/ops">Alle maintenance →</a></p>
</section>
</div>
<div class="hub-grid hm-dash-feed" style="margin-top:1.25rem">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="cto-feed-badge">Live</span></h2>
<div id="cto-agent-feed">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Browser sessies</h2>
{% if browser_sessions %}
{% for s in browser_sessions[:6] %}
<div class="hub-feed-item">
<span>{{ s.created_at[11:16] if s.created_at else '' }}</span>
<span class="badge badge-agent-browser">browser</span>
{{ s.title or s.url or s.task }}
</div>
{% endfor %}
{% else %}
<p class="empty-state">Geen recente browser-sessies.</p>
{% endif %}
<p class="page-tip" style="margin-top:0.75rem"><a href="/browser">Browser agent →</a></p>
</section>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
function ctoDashboard() {
return {
loading: false,
summary: {},
meta: {},
maintenance: [],
opsMeta: 'Laden…',
pendingApprovals: {{ kpis.pending_approvals }},
agentEvents24h: 0,
_pollStop: null,
_wsStop: null,
async init() {
localStorage.setItem('foodlinkk-persona', 'cto');
await this.refresh(false);
await this.loadApprovals();
this._pollStop = CockpitLive.startPolling(() => { this.refresh(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('cto-agent-feed'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('cto-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
async refresh(toast) {
this.loading = true;
try {
const [status, maint, analytics] = await Promise.all([
fetch('/api/ops/status').then(x => x.json()).catch(() => ({})),
fetch('/api/ops/maintenance?limit=12').then(x => x.json()).catch(() => ({ items: [] })),
fetch('/analytics/api/data').then(x => x.json()).catch(() => ({})),
]);
this.summary = status || { counts: {} };
this.meta = (status.topology && status.topology.meta) || status.sources || {};
this.maintenance = (maint.items || []).filter(n => !n.resolved);
this.opsMeta = 'Sync: ' + new Date().toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam' });
if (analytics.kpis) {
this.pendingApprovals = analytics.kpis.pending_approvals ?? this.pendingApprovals;
this.agentEvents24h = analytics.kpis.agent_events ?? 0;
}
if (toast !== false) Cockpit.toast('Platform data bijgewerkt', 'success');
} catch (e) {
if (toast !== false) Cockpit.toast(e.message || 'Fout', 'error');
}
this.loading = false;
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('cto-approvals-list');
if (!el) return;
const items = r.items || [];
this.pendingApprovals = items.length;
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
}
};
}
</script>
{% endblock %}
+157 -60
View File
@@ -2,28 +2,38 @@
{% 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=15" />
<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>Herman · Command Center <span class="live-badge">Live</span></h1>
<p class="hero-sub">CEO overzicht · trending retail · briefing · platform links</p>
<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="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<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="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</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>
@@ -60,7 +70,7 @@
<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' : ''">
<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>
@@ -103,14 +113,6 @@
</div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="rss">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · RSS feed</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> RSS live feed</h3>
<div class="hm-chart-panel">
<div id="briefing-rss-feed" class="hm-rss-panel"></div>
</div>
</section>
<section class="hm-dash-section" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
@@ -152,59 +154,115 @@
<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 class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small id="viz-label-agents">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents"></canvas></div>
<div id="viz-agents-alt" class="viz-alt"></div>
</div>
</div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="approvals" id="widget-approvals">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Goedkeuringen</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen <span class="live-badge" id="approvals-pending-badge" style="display:none">0 open</span></h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt dagelijks toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>. Keur goed → actie wordt automatisch uitgevoerd.</p>
<div id="dash-approvals-list"></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">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% 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">
<section class="panel hm-dash-full">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
<!-- ═══ RSS FEEDS TAB ═══ -->
<div class="herman-rss-hub" x-show="hermanTab === 'rss'" x-cloak>
<header class="herman-rss-head">
<div>
<h2>RSS Feeds</h2>
<p class="page-tip">Alle live feeds op één plek — filter op categorie of zoek op titel</p>
</div>
<div class="herman-rss-actions">
<button type="button" class="btn btn-sm btn-pulse" @click="refreshRss(true)" :disabled="rssLoading">↻ RSS ophalen</button>
<a class="btn btn-sm" href="/marketing?tab=live">Marketing Hub</a>
<a class="btn btn-sm" href="/marketing?tab=bookmarks">★ Bookmarks</a>
</div>
</header>
<div class="herman-rss-filters">
<input type="search" class="form-input" placeholder="Zoek in titels…" x-model="rssSearch" />
<select class="form-input" x-model="rssCategory" @change="loadRssTab()">
<option value="">Alle categorieën</option>
<option value="kant-en-klaar">Kant-en-klaar</option>
<option value="supermarkt">Supermarkt</option>
<option value="food">Food</option>
<option value="markt">Markt &amp; M&amp;A</option>
<option value="regelgeving">Regelgeving</option>
<option value="cbs">CBS</option>
</select>
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
</div>
<div class="herman-rss-grid">
<section class="herman-rss-col herman-rss-col-main">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
<div class="herman-rss-list">
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
<article class="feed-card herman-rss-card">
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
<div class="feed-actions">
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
</div>
</article>
</template>
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
</div>
</section>
<aside class="herman-rss-col herman-rss-col-side">
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
<h4>Regelgeving</h4>
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
<h4>CBS &amp; data</h4>
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="rssBookmarks.length">
<h4>★ Opgeslagen</h4>
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
<div class="herman-rss-side-item">
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
<small x-text="b.feed_name"></small>
</div>
</template>
</section>
</aside>
</div>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=3"></script>
<script src="/static/js/briefing-charts.js?v=12"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=13"></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 || '',
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_prefs: null, _setDragMode: null, _saveTimer: null,
@@ -237,7 +295,51 @@ function dashboardBriefing() {
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;
@@ -272,29 +374,23 @@ function dashboardBriefing() {
const cnt = (this._stats && (this._stats.rss_items || (this._stats.rss_live || []).length)) || 0;
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
}
await this.loadApprovals();
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
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' });
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
if (this._stats && live.items) {
this._stats.rss_live = live.items;
this._stats.rss_items = live.count || live.items.length;
BriefingCharts.renderRssFeed(document.getElementById('briefing-rss-feed'), this._stats);
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 (' + (live.count || 0) + ' items)', 'success');
if (toast) Cockpit.toast('RSS bijgewerkt (' + (this.rssTotal || 0) + ' items)', 'success');
} catch (e) { if (toast) Cockpit.toast(e.message || 'RSS fout', 'error'); }
},
async loadApprovals() {
@@ -363,6 +459,7 @@ function dashboardBriefing() {
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);
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
/* Export Intel — page layout */
.ei-page { --ei-accent: #0ea5e9; }
.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.ei-header h1 { font-size: 1.5rem; margin: 0; }
.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
.ei-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 1rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
border: 1px solid rgba(148,163,184,0.15);
border-radius: 12px;
}
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
.ei-filter-label select,
.ei-filter-label input {
font-size: 0.82rem;
padding: 0.45rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(148,163,184,0.22);
background: #0a0e12;
color: #e2e8f0;
min-width: 8rem;
}
.ei-filter-search-wrap input { width: 100%; min-width: 0; }
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); min-height: 400px; background: #0a0e14; }
.ei-map-wrap #ei-map { height: 100%; min-height: 400px; }
/* Leaflet popups — rich card */
.ei-map-wrap .leaflet-popup-content-wrapper,
.ei-map-wrap .leaflet-popup-tip {
background: #0f1724;
color: #e2e8f0;
border: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
}
.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
.ei-map-wrap .leaflet-popup-close-button {
color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
}
.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
.ei-popup-card { padding: 0.85rem 1rem 1rem; }
.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
.ei-popup-badge {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--badge-color, #38bdf8);
background: rgba(56, 189, 248, 0.12);
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
.ei-map-wrap .leaflet-popup-content .ei-popup-name {
display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
color: #f8fafc !important; line-height: 1.3;
}
.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
.ei-popup-v { color: #cbd5e1; word-break: break-word; }
.ei-popup-v a { color: #38bdf8; text-decoration: none; }
.ei-popup-v a:hover { text-decoration: underline; }
.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
.ei-popup-btn {
width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
}
.ei-popup-btn:hover { filter: brightness(1.08); }
/* Map pins */
.ei-pin-wrap { background: transparent !important; border: none !important; }
.ei-pin {
width: 30px; height: 30px; position: relative;
display: flex; align-items: center; justify-content: center;
}
.ei-pin-ring {
position: absolute; inset: 0; border-radius: 50%;
background: var(--pin-color);
border: 2px solid rgba(255,255,255,0.9);
box-shadow: 0 2px 10px rgba(0,0,0,0.45);
}
.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
/* Clusters */
.ei-cluster-wrap { background: transparent !important; border: none !important; }
.ei-cluster {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #0ea5e9, #0369a1);
border: 2px solid rgba(255,255,255,0.55);
box-shadow: 0 4px 16px rgba(14,165,233,0.45);
color: #fff; font-weight: 800; font-size: 0.85rem;
}
.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
.ei-map-wrap .marker-cluster-small,
.ei-map-wrap .marker-cluster-medium,
.ei-map-wrap .marker-cluster-large { background: transparent !important; }
.ei-map-wrap .marker-cluster-small div,
.ei-map-wrap .marker-cluster-medium div,
.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
.ei-map-legend {
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
}
.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
.ei-leg-pin { font-style: normal; }
.ei-map-empty { margin-bottom: 0.65rem; }
.ei-map-empty-inner {
padding: 0.85rem 1rem;
border-radius: 10px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
font-size: 0.82rem;
color: #cbd5e1;
}
.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
.ei-map-empty-inner p { margin: 0.25rem 0; }
.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
.ei-table-wrap { overflow-x: auto; max-height: 480px; }
.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
.ei-empty-cta { margin-top: 0.75rem; }
.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
.ei-type-distributor { color: #fbbf24; }
.ei-type-wholesaler { color: #eab308; }
.ei-type-contract_caterer { color: #a78bfa; }
.ei-confidence { font-size: 0.65rem; color: #64748b; }
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1100; }
.ei-drawer { position: fixed; top: 0; right: 0; width: min(420px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1101; overflow-y: auto; }
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-drawer-meta strong { color: #e2e8f0; }
.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
.ei-drawer-kv span:first-child { color: #64748b; }
.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
.ei-contact-row a { color: #38bdf8; }
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }

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