SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- Docling conversion results for NAS documents
|
||||
|
||||
CREATE TABLE IF NOT EXISTS docling_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
storage_path TEXT NOT NULL,
|
||||
file_sig VARCHAR(64) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
options JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
exports JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tables_data JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
pictures JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
page_count INT DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_text TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
UNIQUE (storage_path, file_sig)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_docling_results_path ON docling_results (storage_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_docling_results_status ON docling_results (status, created_at DESC);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Editable workspace drafts for Docling / document editor
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_workspace (
|
||||
storage_path TEXT PRIMARY KEY,
|
||||
source_format VARCHAR(32) DEFAULT 'markdown',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_workspace_updated ON document_workspace (updated_at DESC);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Document ↔ klant/project koppelingen + auto-ingest log
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
storage_path TEXT NOT NULL,
|
||||
is_folder BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
client_id INT REFERENCES clients(id) ON DELETE CASCADE,
|
||||
project_id INT REFERENCES cockpit_projects(id) ON DELETE SET NULL,
|
||||
link_type VARCHAR(32) NOT NULL DEFAULT 'nas_share',
|
||||
notes TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(64) DEFAULT 'cockpit'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_links_unique
|
||||
ON document_links (storage_path, COALESCE(client_id, 0), COALESCE(project_id, 0));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_client ON document_links (client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_project ON document_links (project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_links_path ON document_links (storage_path);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ingest_automation_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ingest_automation_log_at ON ingest_automation_log (created_at DESC);
|
||||
+2265
File diff suppressed because it is too large
Load Diff
+130
@@ -0,0 +1,130 @@
|
||||
"""Automatische NAS + second-brain synchronisatie."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
log = logging.getLogger("cockpit.auto_ingest")
|
||||
|
||||
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
|
||||
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
|
||||
|
||||
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
|
||||
(source, status, json.dumps(details)),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("ingest log failed: %s", exc)
|
||||
|
||||
|
||||
def _brain_chat_id_for_client(client_id: int) -> int:
|
||||
return -int(client_id)
|
||||
|
||||
|
||||
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
|
||||
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
|
||||
da.word_count, dl.client_id, c.name AS client_name
|
||||
FROM document_analytics da
|
||||
LEFT JOIN document_links dl ON (
|
||||
da.storage_path = dl.storage_path
|
||||
OR da.storage_path LIKE dl.storage_path || '/%'
|
||||
) AND dl.is_folder = TRUE
|
||||
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
|
||||
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
|
||||
WHERE da.storage_path IS NOT NULL
|
||||
ORDER BY da.analyzed_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 200)),),
|
||||
)
|
||||
synced = 0
|
||||
errors = 0
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
for row in rows:
|
||||
path = row.get("storage_path") or ""
|
||||
if not path:
|
||||
continue
|
||||
client_id = row.get("client_id")
|
||||
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
|
||||
text = (
|
||||
f"NAS document: {row.get('filename') or path}\n"
|
||||
f"Pad: {path}\nType: {row.get('doc_type')}\n"
|
||||
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
|
||||
)
|
||||
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
|
||||
try:
|
||||
r = await client.post(
|
||||
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"direction": "inbound",
|
||||
"role": "system",
|
||||
"content_type": "document",
|
||||
"content_text": text[:4000],
|
||||
"agent_name": "auto-ingest",
|
||||
"embed": True,
|
||||
"chat_type": "client_brain" if client_id else "nas_corpus",
|
||||
"user_name": row.get("client_name") or "NAS",
|
||||
"content_json": {"storage_path": path, "client_id": client_id},
|
||||
"telegram_message_id": msg_hash,
|
||||
},
|
||||
)
|
||||
if r.status_code < 400:
|
||||
synced += 1
|
||||
else:
|
||||
errors += 1
|
||||
except Exception as exc:
|
||||
log.warning("brain sync %s: %s", path, exc)
|
||||
errors += 1
|
||||
return {"synced": synced, "errors": errors, "candidates": len(rows)}
|
||||
|
||||
|
||||
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
|
||||
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
|
||||
out: dict[str, Any] = {"ok": True, "steps": {}}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
|
||||
params={"force": "true" if force_scan else "false"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
out["steps"]["nas_scan"] = r.json()
|
||||
except Exception as exc:
|
||||
out["steps"]["nas_scan"] = {"error": str(exc)}
|
||||
brain = await sync_nas_to_brain()
|
||||
out["steps"]["brain_sync"] = brain
|
||||
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
|
||||
_log_run("auto_sync", status, out)
|
||||
out["last_logged"] = True
|
||||
return out
|
||||
|
||||
|
||||
def last_auto_sync() -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if hasattr(d.get("created_at"), "isoformat"):
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
if isinstance(d.get("details"), str):
|
||||
try:
|
||||
d["details"] = json.loads(d["details"])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
@@ -560,6 +560,7 @@ def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]:
|
||||
SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview,
|
||||
jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count,
|
||||
jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count,
|
||||
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at,
|
||||
session_id, created_at
|
||||
FROM photo_imports ORDER BY created_at DESC LIMIT %s
|
||||
""",
|
||||
@@ -589,7 +590,11 @@ def photos_image(photo_id: int) -> Response:
|
||||
@app.get("/photos/{photo_id}/detections")
|
||||
def photos_detections(photo_id: int) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s",
|
||||
"""
|
||||
SELECT id, detections, extracted_items, ocr_text,
|
||||
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
|
||||
FROM photo_imports WHERE id = %s
|
||||
""",
|
||||
(photo_id,),
|
||||
)
|
||||
if not row:
|
||||
@@ -600,6 +605,9 @@ def photos_detections(photo_id: int) -> dict[str, Any]:
|
||||
"detections": row.get("detections") or [],
|
||||
"extracted_items": row.get("extracted_items") or [],
|
||||
"ocr_text": row.get("ocr_text") or "",
|
||||
"user_labels": row.get("user_labels") or [],
|
||||
"label_notes": row.get("label_notes") or "",
|
||||
"labeled_at": row.get("labeled_at"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import projects as projects_svc
|
||||
|
||||
|
||||
def _serialize_row(row: dict | None) -> dict | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_rows(rows: list) -> list[dict]:
|
||||
return [_serialize_row(r) for r in rows if r]
|
||||
|
||||
|
||||
def get_client_360(client_id: int) -> dict[str, Any]:
|
||||
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
|
||||
if not client:
|
||||
return {"ok": False, "error": "Client not found"}
|
||||
|
||||
projs = projects_svc.list_projects(client_id=client_id, limit=50)
|
||||
links = fetch_all(
|
||||
"""
|
||||
SELECT dl.*, p.name AS project_name
|
||||
FROM document_links dl
|
||||
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
|
||||
WHERE dl.client_id = %s
|
||||
ORDER BY dl.created_at DESC
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
paths = [l["storage_path"] for l in links if l.get("storage_path")]
|
||||
docs: list[dict] = []
|
||||
if paths:
|
||||
placeholders = ",".join(["%s"] * len(paths))
|
||||
docs = fetch_all(
|
||||
f"""
|
||||
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
|
||||
sentiment_compound, analyzed_at, user_labels
|
||||
FROM document_analytics
|
||||
WHERE storage_path IN ({placeholders})
|
||||
OR storage_path LIKE ANY (
|
||||
SELECT dl.storage_path || '/%%' FROM document_links dl
|
||||
WHERE dl.client_id = %s AND dl.is_folder = TRUE
|
||||
)
|
||||
ORDER BY analyzed_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
""",
|
||||
tuple(paths + [client_id]),
|
||||
)
|
||||
sentiment_rows = fetch_all(
|
||||
"""
|
||||
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
|
||||
FROM document_analytics da
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
GROUP BY sentiment_label
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
top_words = fetch_all(
|
||||
"""
|
||||
SELECT dwc.lemma, SUM(dwc.count) AS total
|
||||
FROM document_word_counts dwc
|
||||
JOIN document_analytics da ON da.id = dwc.document_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
AND NOT dwc.is_stopword
|
||||
GROUP BY dwc.lemma
|
||||
ORDER BY total DESC
|
||||
LIMIT 25
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
deals = fetch_all(
|
||||
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
|
||||
(client_id,),
|
||||
)
|
||||
stores = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
|
||||
FROM client_supermarket_links l
|
||||
JOIN supermarkets s ON s.id = l.supermarket_id
|
||||
WHERE l.client_id = %s
|
||||
LIMIT 30
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
trends = fetch_all(
|
||||
"""
|
||||
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
|
||||
COUNT(*) AS docs,
|
||||
AVG(da.sentiment_compound) AS avg_sentiment,
|
||||
SUM(da.word_count) AS words
|
||||
FROM document_analytics da
|
||||
WHERE da.analyzed_at IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
GROUP BY 1
|
||||
ORDER BY 1 DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
for t in trends:
|
||||
if hasattr(t.get("month"), "isoformat"):
|
||||
t["month"] = t["month"].isoformat()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"client": _serialize_row(client),
|
||||
"projects": projs,
|
||||
"links": _serialize_rows(links),
|
||||
"documents": _serialize_rows(docs),
|
||||
"sentiment_breakdown": _serialize_rows(sentiment_rows),
|
||||
"top_words": _serialize_rows(top_words),
|
||||
"deals": _serialize_rows(deals),
|
||||
"stores": _serialize_rows(stores),
|
||||
"monthly_trends": trends,
|
||||
"stats": {
|
||||
"linked_paths": len(links),
|
||||
"documents": len(docs),
|
||||
"projects": len(projs),
|
||||
"stores": len(stores),
|
||||
"deals": len(deals),
|
||||
},
|
||||
}
|
||||
@@ -10,6 +10,10 @@ class Settings:
|
||||
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
|
||||
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
|
||||
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
|
||||
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
||||
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
||||
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
|
||||
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
|
||||
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
|
||||
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
|
||||
+21
-1
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.db import close_pool, fetch_all, init_pool
|
||||
from app.routes import (
|
||||
@@ -31,7 +32,10 @@ from app.routes import (
|
||||
packaging,
|
||||
ops,
|
||||
ops_api,
|
||||
revenue_cockpit,
|
||||
export_intel,
|
||||
)
|
||||
from app.routes.revenue_cockpit import api as revenue_cockpit_api
|
||||
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
|
||||
from app.routes.settings_api import settings_router
|
||||
from app.routes.agents_api import router as agents_api_router
|
||||
@@ -41,8 +45,21 @@ from app.routes.projects_api import router as projects_api_router
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""Serve static assets without browser/SW long-lived caching."""
|
||||
|
||||
async def get_response(self, path: str, scope) -> Response:
|
||||
response = await super().get_response(path, scope)
|
||||
if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
for r in (
|
||||
dashboard.router,
|
||||
@@ -66,6 +83,9 @@ for r in (
|
||||
hermes.router,
|
||||
packaging.router,
|
||||
ops.router,
|
||||
revenue_cockpit.router,
|
||||
export_intel.router,
|
||||
revenue_cockpit_api,
|
||||
api.router,
|
||||
ops_api.router,
|
||||
admin_router,
|
||||
|
||||
+1160
-31
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
|
||||
@router.get("")
|
||||
async def agents_page(request: Request):
|
||||
active_tab = request.query_params.get("tab", "souls")
|
||||
if active_tab not in {"souls", "mesh", "approvals"}:
|
||||
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
|
||||
active_tab = "souls"
|
||||
events: list = []
|
||||
try:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Agents API — souls & activity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import fetch_all
|
||||
from app.services import agent_approvals, agent_souls
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_approvals, agent_integration, agent_souls
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
from app.services.agent_terminal import execute_terminal_command
|
||||
|
||||
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
|
||||
|
||||
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class HandoffBody(BaseModel):
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
handoff_type: str = "partner"
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
|
||||
class TerminalCommandBody(BaseModel):
|
||||
command: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
|
||||
)
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None and created is not None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
return by_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
@router.get("/collaboration")
|
||||
def api_collaboration() -> dict[str, Any]:
|
||||
items = agent_integration.list_collaboration()
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/handoff")
|
||||
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
|
||||
try:
|
||||
handoff = agent_integration.create_handoff(
|
||||
body.from_agent,
|
||||
body.to_agent,
|
||||
handoff_type=body.handoff_type,
|
||||
payload=body.payload,
|
||||
correlation_id=body.correlation_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"ok": True, "handoff": handoff}
|
||||
|
||||
|
||||
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
|
||||
ev["created_at"] = ev["created_at"].isoformat()
|
||||
meta = ev.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
ev["metadata"] = meta or {}
|
||||
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
|
||||
if str(ev.get("event_type") or "").startswith("terminal_"):
|
||||
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
|
||||
if ev["metadata"].get("target_agent"):
|
||||
line_type = "handoff_out"
|
||||
if ev["metadata"].get("source_agent"):
|
||||
line_type = "handoff_in"
|
||||
if str(ev.get("status") or "") in ("error", "rejected"):
|
||||
line_type = "error"
|
||||
return {
|
||||
"type": line_type,
|
||||
"id": ev.get("id"),
|
||||
"agent": normalize_agent_key(ev.get("agent_name")),
|
||||
"message": ev.get("title") or ev.get("event_type"),
|
||||
"detail": (ev.get("body") or "")[:500],
|
||||
"status": ev.get("status"),
|
||||
"correlation_id": ev["metadata"].get("correlation_id"),
|
||||
"at": ev.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/souls/{agent_key}/terminal/stream")
|
||||
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
hist = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
for row in reversed(hist):
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
try:
|
||||
if correlation_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE metadata->>'correlation_id' = %s
|
||||
AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(correlation_id, last_id),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s AND id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
""",
|
||||
(key, last_id),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/live/stream")
|
||||
async def api_agents_live_stream():
|
||||
"""Single realtime stream for all agent terminals."""
|
||||
|
||||
async def event_gen():
|
||||
last_id = 0
|
||||
try:
|
||||
recent = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 minutes'
|
||||
ORDER BY id ASC
|
||||
LIMIT 80
|
||||
"""
|
||||
)
|
||||
for row in recent:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
payload["replay"] = True
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE id > %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 100
|
||||
""",
|
||||
(last_id,),
|
||||
)
|
||||
for row in rows:
|
||||
last_id = max(last_id, int(row["id"]))
|
||||
payload = _terminal_payload(dict(row))
|
||||
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
||||
except Exception as exc:
|
||||
err = {"type": "error", "message": str(exc)}
|
||||
yield f"data: {json.dumps(err)}\n\n"
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/souls")
|
||||
def api_list_souls() -> dict[str, Any]:
|
||||
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
|
||||
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
|
||||
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/souls/{agent_key}/terminal/command")
|
||||
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
|
||||
try:
|
||||
return await execute_terminal_command(agent_key, body.command.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/souls/{agent_key}")
|
||||
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
@router.get("/mesh")
|
||||
def api_agents_mesh() -> dict[str, Any]:
|
||||
souls = agent_souls.list_souls()
|
||||
stats_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS agent_key,
|
||||
MAX(created_at) AS last_event_at,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
|
||||
COUNT(*) FILTER (
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND status IN ('error', 'rejected')
|
||||
) AS errors_24h
|
||||
FROM agent_events
|
||||
GROUP BY LOWER(agent_name)
|
||||
"""
|
||||
)
|
||||
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
|
||||
by_key = _aggregate_agent_stats()
|
||||
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for soul in souls:
|
||||
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
|
||||
row = by_key.get(key, {})
|
||||
events_6h = int(row.get("events_6h") or 0)
|
||||
errors_24h = int(row.get("errors_24h") or 0)
|
||||
health = "offline"
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
health = "healthy"
|
||||
elif events_6h > 0:
|
||||
health = "warn"
|
||||
elif int(soul.get("event_count") or 0) > 0:
|
||||
health = "idle"
|
||||
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
|
||||
is_active = health == "healthy" and events_6h > 0
|
||||
node = dict(soul)
|
||||
node["health"] = health
|
||||
node["events_6h"] = events_6h
|
||||
node["errors_24h"] = errors_24h
|
||||
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
|
||||
node["last_event_at"] = row["last_event_at"].isoformat()
|
||||
node["is_active"] = is_active
|
||||
node["last_event_title"] = row.get("last_event_title")
|
||||
lat = row.get("last_event_at")
|
||||
if lat is not None and hasattr(lat, "isoformat"):
|
||||
node["last_event_at"] = lat.isoformat()
|
||||
nodes.append(node)
|
||||
|
||||
edge_rows = fetch_all(
|
||||
report_edges: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
key = str(node.get("agent_key") or "").lower()
|
||||
if key == "herman" or not node.get("is_active"):
|
||||
continue
|
||||
report_edges.append(
|
||||
{
|
||||
"source": key,
|
||||
"target": "herman",
|
||||
"type": "report",
|
||||
"active": True,
|
||||
"weight": int(node.get("events_6h") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
delegate_rows = fetch_all(
|
||||
"""
|
||||
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
|
||||
SELECT metadata, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
AND LOWER(agent_name) <> 'herman'
|
||||
GROUP BY LOWER(agent_name)
|
||||
ORDER BY weight DESC
|
||||
WHERE agent_name = 'herman'
|
||||
AND created_at >= NOW() - INTERVAL '6 hours'
|
||||
AND (
|
||||
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
|
||||
OR metadata ? 'delegated'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
|
||||
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
|
||||
delegate_edges: list[dict[str, Any]] = []
|
||||
seen_delegate: set[tuple[str, str]] = set()
|
||||
for r in delegate_rows:
|
||||
meta = r.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
|
||||
if isinstance(delegated, str):
|
||||
delegated = [delegated]
|
||||
channel = meta.get("channel") or "herman"
|
||||
for agent in delegated:
|
||||
tgt = normalize_agent_key(str(agent))
|
||||
if not tgt or tgt == "herman":
|
||||
continue
|
||||
pair = ("herman", tgt)
|
||||
if pair in seen_delegate:
|
||||
continue
|
||||
seen_delegate.add(pair)
|
||||
delegate_edges.append(
|
||||
{
|
||||
"source": "herman",
|
||||
"target": tgt,
|
||||
"type": "delegate",
|
||||
"active": True,
|
||||
"channel": channel,
|
||||
}
|
||||
)
|
||||
|
||||
peer_static = [
|
||||
{
|
||||
"source": str(r["from_agent"]),
|
||||
"target": str(r["to_agent"]),
|
||||
"type": "static",
|
||||
"handoff_type": r.get("handoff_type"),
|
||||
}
|
||||
for r in agent_integration.list_collaboration()
|
||||
]
|
||||
peer_live = agent_integration.peer_edges_live(hours=6)
|
||||
peer_edges = peer_static + peer_live
|
||||
|
||||
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
|
||||
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
|
||||
executive_edges = []
|
||||
if herman_active:
|
||||
executive_edges = [
|
||||
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
|
||||
{"source": "herman", "target": "cto", "type": "executive", "active": True},
|
||||
]
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"report_edges": report_edges,
|
||||
"delegate_edges": delegate_edges,
|
||||
"peer_edges": peer_edges,
|
||||
"executive_edges": executive_edges,
|
||||
"edges": report_edges,
|
||||
"executives": [
|
||||
{"id": "ceo", "label": "CEO", "role": "Aissa"},
|
||||
{"id": "cto", "label": "CTO", "role": "Platform"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
|
||||
auto = None
|
||||
rid = (req or {}).get("id")
|
||||
if rid:
|
||||
try:
|
||||
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
|
||||
except Exception:
|
||||
auto = None
|
||||
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
|
||||
if auto:
|
||||
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
|
||||
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/approve")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -73,7 +73,8 @@ async def documents_page(request: Request):
|
||||
SELECT filename, storage_path, doc_type, language, word_count,
|
||||
unique_lemmas, sentiment_label, sentiment_compound,
|
||||
sentiment_positive, sentiment_negative, sentiment_neutral,
|
||||
extraction_method, analyzed_at
|
||||
extraction_method, analyzed_at,
|
||||
COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
|
||||
FROM document_analytics
|
||||
ORDER BY analyzed_at DESC
|
||||
LIMIT 50
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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 = []
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Revenue Cockpit — page + API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import revenue_cockpit as svc
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
router = APIRouter(tags=["revenue-cockpit"])
|
||||
api = APIRouter(prefix="/api/revenue-cockpit", tags=["revenue-cockpit-api"])
|
||||
|
||||
|
||||
class ProjectUpdateBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
margin_month: Optional[float] = None
|
||||
margin_year: Optional[float] = None
|
||||
target_revenue: Optional[float] = None
|
||||
next_steps: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
row_style: Optional[str] = None
|
||||
|
||||
|
||||
class GoalsUpdateBody(BaseModel):
|
||||
vision_text: Optional[str] = None
|
||||
horizon_text: Optional[str] = None
|
||||
mid_text: Optional[str] = None
|
||||
tagline: Optional[str] = None
|
||||
|
||||
|
||||
class ObjectiveBody(BaseModel):
|
||||
title: str = Field(..., min_length=1)
|
||||
description: Optional[str] = None
|
||||
priority: str = "normal"
|
||||
due_date: Optional[str] = None
|
||||
|
||||
|
||||
class ObjectiveUpdateBody(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
due_date: Optional[str] = None
|
||||
|
||||
|
||||
class AssignTaskBody(BaseModel):
|
||||
agent_name: str = Field(..., min_length=1)
|
||||
title: str = Field(..., min_length=1)
|
||||
description: Optional[str] = None
|
||||
objective_id: Optional[int] = None
|
||||
priority: str = "normal"
|
||||
delegate_herman: bool = True
|
||||
|
||||
|
||||
class DelegateBody(BaseModel):
|
||||
message: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ImportBody(BaseModel):
|
||||
path: str = "Succes Sheet .xlsx"
|
||||
sheet: Optional[str] = "Projects next steps revenue"
|
||||
replace: bool = True
|
||||
|
||||
|
||||
@router.get("/revenue-cockpit", response_class=HTMLResponse)
|
||||
async def revenue_cockpit_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"revenue_cockpit.html",
|
||||
{"request": request, "page_title": "Revenue Cockpit"},
|
||||
)
|
||||
|
||||
|
||||
@api.get("/live")
|
||||
async def live_from_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
|
||||
"""Leading data source: fresh parse from NAS Excel."""
|
||||
try:
|
||||
parsed = await svc.fetch_excel_parse_async(path, sheet)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
projects = parsed.get("projects") or []
|
||||
margin_month = sum(p.get("margin_month") or 0 for p in projects)
|
||||
margin_year = sum(p.get("margin_year") or 0 for p in projects)
|
||||
by_style: dict[str, int] = {}
|
||||
by_category: dict[str, int] = {}
|
||||
for p in projects:
|
||||
by_style[p.get("row_style") or "white"] = by_style.get(p.get("row_style") or "white", 0) + 1
|
||||
by_category[p.get("category") or "deal"] = by_category.get(p.get("category") or "deal", 0) + 1
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "excel",
|
||||
**parsed,
|
||||
"aggregates": {
|
||||
"total_margin_month": margin_month,
|
||||
"total_margin_year": margin_year,
|
||||
"with_margin": sum(1 for p in projects if p.get("margin_month")),
|
||||
"by_style": by_style,
|
||||
"by_category": by_category,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@api.get("/dashboard")
|
||||
async def dashboard():
|
||||
return {"ok": True, "stats": svc.dashboard_stats(), "projects": svc.list_projects()}
|
||||
|
||||
|
||||
@api.get("/projects")
|
||||
def list_projects(status: Optional[str] = None):
|
||||
return {"ok": True, "items": svc.list_projects(status)}
|
||||
|
||||
|
||||
@api.get("/projects/{project_id}")
|
||||
def get_project(project_id: int):
|
||||
p = svc.get_project(project_id)
|
||||
if not p:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return {"ok": True, "project": p}
|
||||
|
||||
|
||||
@api.patch("/projects/{project_id}")
|
||||
def patch_project(project_id: int, body: ProjectUpdateBody):
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
row_style = data.pop("row_style", None)
|
||||
if row_style is not None:
|
||||
svc.set_project_row_style(project_id, row_style)
|
||||
p = svc.update_project(project_id, data)
|
||||
if not p:
|
||||
raise HTTPException(404, "Project not found")
|
||||
svc.take_snapshot()
|
||||
return {"ok": True, "project": p}
|
||||
|
||||
|
||||
@api.patch("/goals")
|
||||
def patch_goals(body: GoalsUpdateBody):
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
g = svc.update_goals(data)
|
||||
if not g:
|
||||
raise HTTPException(404, "Goals not found")
|
||||
return {"ok": True, "goals": g}
|
||||
|
||||
|
||||
@api.post("/projects/{project_id}/objectives")
|
||||
def add_objective(project_id: int, body: ObjectiveBody):
|
||||
try:
|
||||
obj = svc.create_objective(project_id, body.title, body.description, body.priority)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
svc.take_snapshot()
|
||||
return {"ok": True, "objective": obj}
|
||||
|
||||
|
||||
@api.patch("/objectives/{objective_id}")
|
||||
def patch_objective(objective_id: int, body: ObjectiveUpdateBody):
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
obj = svc.update_objective(objective_id, data)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Objective not found")
|
||||
svc.take_snapshot()
|
||||
return {"ok": True, "objective": obj}
|
||||
|
||||
|
||||
@api.post("/projects/{project_id}/assign-task")
|
||||
def assign_task(project_id: int, body: AssignTaskBody):
|
||||
try:
|
||||
task = svc.assign_agent_task(
|
||||
project_id,
|
||||
body.agent_name,
|
||||
body.title,
|
||||
body.description,
|
||||
body.objective_id,
|
||||
body.priority,
|
||||
body.delegate_herman,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
svc.take_snapshot()
|
||||
return {"ok": True, "task": task}
|
||||
|
||||
|
||||
@api.post("/projects/{project_id}/delegate")
|
||||
async def delegate_project(project_id: int, body: DelegateBody):
|
||||
try:
|
||||
result = await svc.delegate_via_herman(project_id, body.message)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return result
|
||||
|
||||
|
||||
@api.get("/tasks")
|
||||
def list_tasks(limit: int = 50):
|
||||
return {"ok": True, "items": svc.list_agent_tasks(limit)}
|
||||
|
||||
|
||||
@api.get("/snapshots")
|
||||
def snapshots(limit: int = 90):
|
||||
return {"ok": True, "items": svc.list_snapshots(limit)}
|
||||
|
||||
|
||||
@api.post("/snapshot")
|
||||
def create_snapshot():
|
||||
snap = svc.take_snapshot()
|
||||
return {"ok": True, "snapshot": snap}
|
||||
|
||||
|
||||
@api.post("/import-from-excel")
|
||||
async def import_from_excel(body: ImportBody):
|
||||
try:
|
||||
parsed = await svc.fetch_excel_parse_async(body.path, body.sheet)
|
||||
result = svc.import_from_parsed(parsed, imported_by="ceo", replace=body.replace)
|
||||
return {"ok": True, **result, "preview": {"project_count": parsed.get("project_count"), "sheet": parsed.get("sheet_name")}}
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Import failed: {exc}") from exc
|
||||
|
||||
|
||||
@api.get("/preview-excel")
|
||||
async def preview_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
|
||||
try:
|
||||
parsed = await svc.fetch_excel_parse_async(path, sheet)
|
||||
return {"ok": True, **parsed}
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
@@ -10,7 +10,7 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(request: Request, tab: str = "email"):
|
||||
allowed = ("email", "general", "permissions", "social")
|
||||
allowed = ("email", "general", "permissions", "social", "ai")
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
|
||||
@@ -381,3 +381,118 @@ def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
|
||||
def grant_all_permissions() -> dict[str, Any]:
|
||||
n = agent_souls.grant_all_permissions()
|
||||
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
|
||||
|
||||
|
||||
# ── LLM providers ──────────────────────────────────────────────────────────
|
||||
|
||||
from app.services import llm_router
|
||||
|
||||
|
||||
class LlmProviderBody(BaseModel):
|
||||
label: str = Field(..., max_length=128)
|
||||
provider_type: str = Field(default="deepseek", max_length=48)
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model: str = Field(default="deepseek-chat", max_length=128)
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
extra_config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@settings_router.get("/llm/presets")
|
||||
def llm_presets() -> dict[str, Any]:
|
||||
return {"presets": llm_router.list_presets()}
|
||||
|
||||
|
||||
@settings_router.get("/llm")
|
||||
def llm_list() -> dict[str, Any]:
|
||||
items = llm_router.list_providers()
|
||||
default = next((i for i in items if i.get("is_default")), items[0] if items else None)
|
||||
return {"providers": items, "default": default, "presets": llm_router.list_presets()}
|
||||
|
||||
|
||||
@settings_router.post("/llm")
|
||||
def llm_create(body: LlmProviderBody) -> dict[str, Any]:
|
||||
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
|
||||
base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
|
||||
model = body.model or (preset.get("models") or ["deepseek-chat"])[0]
|
||||
if body.is_default:
|
||||
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||||
row = fetch_one(
|
||||
"""INSERT INTO llm_providers (
|
||||
label, provider_type, api_base_url, api_key, model,
|
||||
is_active, is_default, extra_config, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||
RETURNING *""",
|
||||
(
|
||||
body.label,
|
||||
body.provider_type,
|
||||
base_url,
|
||||
body.api_key or "",
|
||||
model,
|
||||
body.is_active,
|
||||
body.is_default,
|
||||
json.dumps(body.extra_config or {}),
|
||||
),
|
||||
)
|
||||
return {"provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.put("/llm/{provider_id}")
|
||||
def llm_update(provider_id: int, body: LlmProviderBody) -> dict[str, Any]:
|
||||
existing = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if not existing:
|
||||
raise HTTPException(404, "Provider niet gevonden")
|
||||
preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
|
||||
base_url = (body.api_base_url or preset.get("api_base_url") or existing.get("api_base_url") or "").strip() or None
|
||||
api_key = body.api_key if body.api_key else existing.get("api_key") or ""
|
||||
if body.is_default:
|
||||
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
|
||||
row = fetch_one(
|
||||
"""UPDATE llm_providers SET
|
||||
label = %s, provider_type = %s, api_base_url = %s, api_key = %s, model = %s,
|
||||
is_active = %s, is_default = %s, extra_config = %s::jsonb, updated_at = NOW()
|
||||
WHERE id = %s RETURNING *""",
|
||||
(
|
||||
body.label,
|
||||
body.provider_type,
|
||||
base_url,
|
||||
api_key,
|
||||
body.model,
|
||||
body.is_active,
|
||||
body.is_default,
|
||||
json.dumps(body.extra_config or {}),
|
||||
provider_id,
|
||||
),
|
||||
)
|
||||
return {"provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.delete("/llm/{provider_id}")
|
||||
def llm_delete(provider_id: int) -> dict[str, Any]:
|
||||
row = fetch_one("SELECT is_default FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Provider niet gevonden")
|
||||
execute("DELETE FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
if row.get("is_default"):
|
||||
execute(
|
||||
"""UPDATE llm_providers SET is_default = TRUE, updated_at = NOW()
|
||||
WHERE id = (SELECT id FROM llm_providers ORDER BY id LIMIT 1)"""
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@settings_router.post("/llm/{provider_id}/activate")
|
||||
def llm_activate(provider_id: int) -> dict[str, Any]:
|
||||
llm_router.set_default(provider_id)
|
||||
row = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
|
||||
return {"ok": True, "provider": llm_router._mask_provider(row)}
|
||||
|
||||
|
||||
@settings_router.post("/llm/{provider_id}/test")
|
||||
async def llm_test(provider_id: int) -> dict[str, Any]:
|
||||
ok, msg = await llm_router.test_provider(provider_id)
|
||||
if not ok:
|
||||
raise HTTPException(502, msg)
|
||||
return {"ok": True, "message": msg}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,3 +191,48 @@ def require_approved(request_id: int) -> dict[str, Any]:
|
||||
if req.get("status") != "approved":
|
||||
raise PermissionError(f"Request status is {req.get('status')}, approval required")
|
||||
return req
|
||||
|
||||
|
||||
def _auto_approve_policy() -> dict[str, bool]:
|
||||
try:
|
||||
row = fetch_one("SELECT value FROM app_settings WHERE key = 'sysops_auto_approve'")
|
||||
if row and row.get("value"):
|
||||
val = row["value"]
|
||||
if isinstance(val, str):
|
||||
return json.loads(val)
|
||||
return dict(val)
|
||||
except Exception:
|
||||
pass
|
||||
return {"maintenance_scan": True, "config_backup": True}
|
||||
|
||||
|
||||
def try_auto_approve_and_execute(request_id: int) -> dict[str, Any] | None:
|
||||
"""Auto-approve low-risk SysOps requests when policy allows."""
|
||||
req = get_request(request_id)
|
||||
if not req or req.get("status") != "pending":
|
||||
return None
|
||||
key = str(req.get("agent_key") or "").lower()
|
||||
action = str(req.get("action_type") or "")
|
||||
policy = _auto_approve_policy()
|
||||
if key != "sysops" or not policy.get(action):
|
||||
return None
|
||||
approved = approve_request(request_id, approved_by="auto_policy")
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
result_payload: dict[str, Any] = {}
|
||||
try:
|
||||
if action == "config_backup":
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
resp = client.post(f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id})
|
||||
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
|
||||
elif action == "maintenance_scan":
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.post(f"{tools_url}/ops/maintenance/scan")
|
||||
result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
|
||||
except Exception as exc:
|
||||
result_payload = {"ok": False, "detail": str(exc)}
|
||||
executed = mark_executed(request_id, result_payload)
|
||||
return {"approved": approved, "executed": executed, "auto": True}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Log agent activity to agent_events for terminals and audit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import fetch_one
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
|
||||
def log_agent_event(
|
||||
agent_name: str,
|
||||
event_type: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
*,
|
||||
agent_type: Optional[str] = None,
|
||||
status: str = "completed",
|
||||
channel: str = "cockpit",
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
key = normalize_agent_key(agent_name)
|
||||
if not key:
|
||||
return None
|
||||
meta = json.dumps(metadata or {})
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
||||
""",
|
||||
(
|
||||
key,
|
||||
agent_type or key,
|
||||
event_type,
|
||||
title[:255],
|
||||
(body or "")[:8000],
|
||||
status,
|
||||
channel,
|
||||
meta,
|
||||
),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
if out.get("created_at") and hasattr(out["created_at"], "isoformat"):
|
||||
out["created_at"] = out["created_at"].isoformat()
|
||||
return out
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Agent handoffs and collaboration matrix."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
elif k == "correlation_id" and v is not None:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def list_collaboration() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"SELECT from_agent, to_agent, handoff_type, description FROM agent_collaboration ORDER BY from_agent, to_agent"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_handoff(
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
*,
|
||||
handoff_type: str = "partner",
|
||||
payload: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
) -> dict[str, Any]:
|
||||
src = normalize_agent_key(from_agent)
|
||||
dst = normalize_agent_key(to_agent)
|
||||
if not src or not dst:
|
||||
raise ValueError("from_agent and to_agent required")
|
||||
cid = correlation_id or str(uuid.uuid4())
|
||||
try:
|
||||
uuid.UUID(str(cid))
|
||||
except ValueError:
|
||||
cid = str(uuid.uuid4())
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO agent_handoffs (correlation_id, from_agent, to_agent, handoff_type, payload, status, completed_at)
|
||||
VALUES (%s::uuid, %s, %s, %s, %s::jsonb, %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END)
|
||||
RETURNING *
|
||||
""",
|
||||
(cid, src, dst, handoff_type, json.dumps(payload or {}), status, status),
|
||||
)
|
||||
handoff = _serialize(row) or {}
|
||||
meta = {
|
||||
"correlation_id": cid,
|
||||
"handoff_id": handoff.get("id"),
|
||||
"target_agent": dst,
|
||||
"source_agent": src,
|
||||
"handoff_type": handoff_type,
|
||||
}
|
||||
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
|
||||
return handoff
|
||||
|
||||
|
||||
def _log_handoff_events(
|
||||
src: str,
|
||||
dst: str,
|
||||
handoff_type: str,
|
||||
payload: dict[str, Any],
|
||||
meta: dict[str, Any],
|
||||
cid: str,
|
||||
) -> None:
|
||||
title_out = f"{src} → {dst}: {handoff_type}"
|
||||
title_in = f"Handoff van {src}: {handoff_type}"
|
||||
body = json.dumps(payload)[:2000] if payload else ""
|
||||
for agent, etype, title, extra in (
|
||||
(src, "handoff_out", title_out, {"target_agent": dst}),
|
||||
(dst, "handoff_in", title_in, {"source_agent": src}),
|
||||
):
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
agent,
|
||||
"agent_handoff",
|
||||
etype,
|
||||
title[:255],
|
||||
body,
|
||||
"completed",
|
||||
"agents",
|
||||
json.dumps({**meta, **extra}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def recent_handoffs(hours: int = 6) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT * FROM agent_handoffs
|
||||
WHERE created_at >= NOW() - make_interval(hours => %s)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(max(1, min(hours, 168)),),
|
||||
)
|
||||
return [_serialize(r) for r in rows if r]
|
||||
|
||||
|
||||
def peer_edges_live(hours: int = 6) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT from_agent, to_agent, handoff_type, COUNT(*) AS weight,
|
||||
MAX(correlation_id::text) AS correlation_id
|
||||
FROM agent_handoffs
|
||||
WHERE created_at >= NOW() - make_interval(hours => %s)
|
||||
AND status = 'completed'
|
||||
GROUP BY from_agent, to_agent, handoff_type
|
||||
""",
|
||||
(max(1, min(hours, 168)),),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"source": str(r["from_agent"]),
|
||||
"target": str(r["to_agent"]),
|
||||
"type": "live",
|
||||
"handoff_type": r.get("handoff_type"),
|
||||
"weight": int(r["weight"] or 1),
|
||||
"correlation_id": r.get("correlation_id"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Normalize legacy agent_name values to agent_souls keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
AGENT_NAME_MAP: dict[str, str] = {
|
||||
"retail_360": "retail",
|
||||
"retail_scraper": "retail",
|
||||
"retail_crm": "retail",
|
||||
"retail_intel": "retail",
|
||||
"rss_feeds": "marketing",
|
||||
"wholesale_scraper": "sourcing",
|
||||
"halal_registry": "halal",
|
||||
"branch_scraper": "sourcing",
|
||||
"hermes": "herman",
|
||||
"herman_delegate": "herman",
|
||||
}
|
||||
|
||||
|
||||
def normalize_agent_key(name: str | None) -> str:
|
||||
key = (name or "").strip().lower()
|
||||
if not key:
|
||||
return ""
|
||||
return AGENT_NAME_MAP.get(key, key)
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Interactive agent terminal — parse and execute whitelisted commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_integration, agent_souls, herman, webbuilder_agent
|
||||
from app.services.agent_events_log import log_agent_event
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
|
||||
|
||||
Line = dict[str, Any]
|
||||
|
||||
GLOBAL_HELP = [
|
||||
"help — dit overzicht",
|
||||
"status — huidige status & taak",
|
||||
"history — laatste events (in terminal)",
|
||||
"handoff <agent> <type> [notitie]",
|
||||
"say <bericht> — log een notitie",
|
||||
"ask <vraag> — AI-antwoord in rol van deze agent",
|
||||
]
|
||||
|
||||
AGENT_HELP: dict[str, list[str]] = {
|
||||
"herman": [
|
||||
"briefing — dagrapport genereren",
|
||||
"<tekst> — vraag / opdracht aan Herman",
|
||||
],
|
||||
"browser": [
|
||||
"monitor add <url> — site toevoegen aan monitor",
|
||||
"monitor list — actieve sites",
|
||||
"browse <url> — pagina openen",
|
||||
"crawl — alle sites crawlen + parse-overzicht",
|
||||
"parse — laatste parse-resultaten",
|
||||
"parse <id> — parse van specifieke site",
|
||||
"intel — hype & trend analyse (samenvatting)",
|
||||
"intel <zoekterm> — filter analyse",
|
||||
],
|
||||
"research": ["run — research pipeline starten", "briefs — recente briefs"],
|
||||
"retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
|
||||
"sysops": [
|
||||
"backup — config backup (approval)",
|
||||
"scan — maintenance scan",
|
||||
"topology — infra overzicht",
|
||||
"status — sysops status",
|
||||
],
|
||||
"marketing": ["reco — aanbevelingen genereren"],
|
||||
"packaging": ["design <brief> — packaging opdracht (via Herman)"],
|
||||
"webbuilder": [
|
||||
"build <opdracht> — website bouwen (agy op Hermes)",
|
||||
"projects — bestaande website-projecten",
|
||||
"status [project] — build-status",
|
||||
"preview [project] — preview-URL",
|
||||
],
|
||||
"email": ["sync — inbox sync trigger"],
|
||||
"finance": ["margins — marge-overzicht (placeholder)"],
|
||||
}
|
||||
|
||||
|
||||
def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
|
||||
return {"type": line_type, "message": msg, "detail": detail}
|
||||
|
||||
|
||||
def _help_lines(key: str) -> list[Line]:
|
||||
lines = [_line("Beschikbare commando's:", line_type="output")]
|
||||
for h in GLOBAL_HELP:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
extra = AGENT_HELP.get(key, [])
|
||||
if extra:
|
||||
lines.append(_line(f"— {key} —", line_type="output"))
|
||||
for h in extra:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
|
||||
recent = soul.get("recent_events") or []
|
||||
last = recent[0] if recent else {}
|
||||
lines = [
|
||||
_line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
|
||||
_line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
|
||||
_line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
|
||||
_line(f"Events totaal: {soul.get('event_count') or 0}"),
|
||||
]
|
||||
resp = (soul.get("responsibilities") or "").strip()
|
||||
if resp:
|
||||
lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
|
||||
return lines
|
||||
|
||||
|
||||
async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "detail": resp.text[:500]}
|
||||
if resp.status_code >= 400:
|
||||
data.setdefault("ok", False)
|
||||
data.setdefault("detail", resp.text[:500])
|
||||
return data
|
||||
|
||||
|
||||
async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{TOOLS_URL}{path}")
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
|
||||
dst = normalize_agent_key(dst_raw)
|
||||
if not dst:
|
||||
return [_line("Onbekende agent: " + dst_raw, line_type="error")]
|
||||
payload = {"note": note} if note else {}
|
||||
try:
|
||||
agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
|
||||
return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
|
||||
except Exception as exc:
|
||||
return [_line("Handoff mislukt: " + str(exc), line_type="error")]
|
||||
|
||||
|
||||
async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
|
||||
name = soul.get("display_name") or key
|
||||
role = soul.get("role_title") or ""
|
||||
prompt = (
|
||||
f"Je bent {name} ({key}), {role}. "
|
||||
f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
|
||||
)
|
||||
result = await herman.chat(prompt, channel=f"terminal:{key}")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_out",
|
||||
reply[:255] if reply else "Antwoord",
|
||||
reply[:4000],
|
||||
channel="terminal",
|
||||
metadata={"delegated": delegated, "question": question[:500]},
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
|
||||
if result.get("message") and not result.get("results"):
|
||||
return [_line(result["message"], line_type="output")]
|
||||
lines: list[Line] = [
|
||||
_line(
|
||||
f"Crawl klaar — {result.get('sites', 0)} site(s), "
|
||||
f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
|
||||
)
|
||||
]
|
||||
for r in result.get("results") or []:
|
||||
if r.get("status") == "ERROR":
|
||||
lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
|
||||
continue
|
||||
flag = "△" if r.get("changed") else "✓"
|
||||
lines.append(
|
||||
_line(
|
||||
f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
|
||||
f"{r.get('links_count', 0)} links",
|
||||
detail=(r.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
headings = r.get("headings") or []
|
||||
if headings:
|
||||
lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
|
||||
for link in (r.get("links_sample") or [])[:3]:
|
||||
lbl = link.get("label") or link.get("href") or ""
|
||||
lines.append(_line(" → " + lbl[:70], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
|
||||
if not items:
|
||||
return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
|
||||
lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
|
||||
for p in items:
|
||||
lines.append(
|
||||
_line(
|
||||
f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
|
||||
f"{p.get('word_count', 0)} woorden",
|
||||
detail=(p.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
if p.get("meta_description"):
|
||||
lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
|
||||
for h in (p.get("headings") or [])[:3]:
|
||||
lines.append(_line(" · " + h[:90], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
|
||||
if key == "herman":
|
||||
if cmd == "briefing":
|
||||
try:
|
||||
content = await herman.generate_briefing()
|
||||
preview = (content or "")[:400]
|
||||
log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
|
||||
return [_line("CEO briefing gegenereerd.", detail=preview)]
|
||||
except Exception as exc:
|
||||
return [_line("Briefing mislukt: " + str(exc), line_type="error")]
|
||||
result = await herman.chat(raw, channel="terminal")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
|
||||
return lines
|
||||
|
||||
if key == "browser":
|
||||
if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
|
||||
url = args[1]
|
||||
from app.services.monitor import add_site
|
||||
|
||||
site = add_site(url, name=url[:80])
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_site_added",
|
||||
f"Terminal: site toegevoegd {url[:120]}",
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"url": url, "site_id": site.get("id")},
|
||||
)
|
||||
return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
|
||||
if cmd == "monitor" and args and args[0].lower() == "list":
|
||||
rows = fetch_all(
|
||||
"SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
|
||||
)
|
||||
if not rows:
|
||||
return [_line("Geen monitor-sites.")]
|
||||
return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
|
||||
if cmd == "browse" and args:
|
||||
url = args[0]
|
||||
data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
|
||||
log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
|
||||
title = data.get("title") or data.get("url") or url
|
||||
return [_line(f"Browse OK: {title}")]
|
||||
if cmd == "crawl":
|
||||
from app.services.monitor import trigger_crawl
|
||||
|
||||
site_id = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
result = trigger_crawl(site_id)
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_crawl",
|
||||
f"Terminal crawl ({result.get('sites', 0)} sites)",
|
||||
json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
|
||||
channel="terminal",
|
||||
)
|
||||
return _format_crawl_results(result)
|
||||
if cmd == "parse":
|
||||
from app.services.monitor import list_parse_results
|
||||
|
||||
sid = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
return _format_parse_items(list_parse_results(site_id=sid, limit=10))
|
||||
if cmd == "intel":
|
||||
from app.services.monitor import build_parse_intelligence
|
||||
|
||||
q = " ".join(args) if args else None
|
||||
data = build_parse_intelligence(query=q, limit=20)
|
||||
lines = [
|
||||
_line(
|
||||
f"Analyse: {data['summary']['pages']} pagina's · "
|
||||
f"{data['summary']['total_words']} woorden · "
|
||||
f"{data['summary']['themes_detected']} trend-termen"
|
||||
),
|
||||
_line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
|
||||
]
|
||||
for t in (data.get("hype_terms") or [])[:12]:
|
||||
flag = "★ " if t.get("cross_site") else ""
|
||||
lines.append(
|
||||
_line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
|
||||
)
|
||||
return lines
|
||||
|
||||
if key == "research":
|
||||
if cmd == "run":
|
||||
data = await _tools_post("/research/run")
|
||||
ok = data.get("ok", True)
|
||||
log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
|
||||
return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
|
||||
if cmd == "briefs":
|
||||
data = await _tools_get("/research/briefs")
|
||||
items = data if isinstance(data, list) else data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen briefs.")]
|
||||
return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
|
||||
|
||||
if key == "retail":
|
||||
if cmd == "rss":
|
||||
data = await _tools_post("/retail/rss/refresh")
|
||||
log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
|
||||
return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
|
||||
if cmd == "scores":
|
||||
data = await _tools_post("/retail/compute-opportunities")
|
||||
return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
|
||||
|
||||
if key == "sysops":
|
||||
if cmd == "backup":
|
||||
data = await _tools_post("/ops/backup/run")
|
||||
log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
|
||||
return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "scan":
|
||||
data = await _tools_post("/ops/maintenance/scan")
|
||||
return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "topology":
|
||||
data = await _tools_get("/ops/topology")
|
||||
return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
|
||||
if cmd == "status":
|
||||
data = await _tools_get("/ops/status")
|
||||
return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
|
||||
|
||||
if key == "marketing" and cmd == "reco":
|
||||
data = await _tools_post("/recommendations/generate")
|
||||
return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
|
||||
|
||||
if key == "packaging" and cmd == "design":
|
||||
brief = " ".join(args) or raw
|
||||
result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
return [_line(reply, detail=reply)]
|
||||
|
||||
if key == "webbuilder":
|
||||
if cmd == "build":
|
||||
brief = rest or raw
|
||||
if not brief:
|
||||
return [_line("Gebruik: build <opdracht>", line_type="error")]
|
||||
try:
|
||||
outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
|
||||
proj = outcome.get("project") or "?"
|
||||
preview = outcome.get("preview_url") or "—"
|
||||
return [
|
||||
_line(f"Build gestart: {proj}"),
|
||||
_line(f"Preview: {preview}", line_type="output"),
|
||||
_line("Volg voortgang live in dit terminal-venster.", line_type="output"),
|
||||
]
|
||||
except Exception as exc:
|
||||
return [_line("Build mislukt: " + str(exc), line_type="error")]
|
||||
if cmd == "projects":
|
||||
data = await webbuilder_agent.list_projects()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen projecten op Hermes/NAS.")]
|
||||
return [
|
||||
_line(
|
||||
f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
|
||||
+ (" · index.html ✓" if p.get("has_index") else "")
|
||||
)
|
||||
for p in items
|
||||
]
|
||||
if cmd == "status":
|
||||
slug = args[0] if args else ""
|
||||
if not slug:
|
||||
return [_line("Gebruik: status <project>", line_type="error")]
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
|
||||
tail = (st.get("log_tail") or "")[:300]
|
||||
if tail:
|
||||
lines.append(_line(tail, line_type="output"))
|
||||
return lines
|
||||
if cmd == "preview":
|
||||
slug = args[0] if args else ""
|
||||
if slug:
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
else:
|
||||
url = webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
return [_line(f"Preview: {url}")]
|
||||
|
||||
return await _agent_ask(key, soul, raw)
|
||||
|
||||
|
||||
async def execute_terminal_command(
|
||||
agent_key: str,
|
||||
command: str,
|
||||
*,
|
||||
issued_by: str = "ceo",
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise ValueError("Agent niet gevonden")
|
||||
|
||||
raw = (command or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Leeg commando")
|
||||
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_in",
|
||||
raw[:255],
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"issued_by": issued_by, "command": raw[:500]},
|
||||
)
|
||||
|
||||
parts = raw.split()
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:]
|
||||
rest = " ".join(args)
|
||||
|
||||
if cmd in ("help", "?", "h"):
|
||||
lines = _help_lines(key)
|
||||
elif cmd == "status":
|
||||
lines = _status_lines(key, soul)
|
||||
elif cmd == "handoff":
|
||||
if len(args) < 2:
|
||||
lines = [_line("Gebruik: handoff <agent> <type> [notitie]", line_type="error")]
|
||||
else:
|
||||
note = " ".join(args[2:]) if len(args) > 2 else ""
|
||||
lines = _handoff(key, args[0], args[1], note)
|
||||
elif cmd == "say":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: say <bericht>", line_type="error")]
|
||||
else:
|
||||
log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
|
||||
lines = [_line("Genoteerd.")]
|
||||
elif cmd == "ask":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: ask <vraag>", line_type="error")]
|
||||
else:
|
||||
lines = await _agent_ask(key, soul, rest)
|
||||
elif cmd == "history":
|
||||
events = agent_souls.list_agent_events(key, limit=8)
|
||||
if not events:
|
||||
lines = [_line("Geen history.")]
|
||||
else:
|
||||
lines = []
|
||||
for ev in reversed(events):
|
||||
t = (ev.get("created_at") or "")[11:19] or "--:--"
|
||||
lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
|
||||
else:
|
||||
lines = await _run_agent_command(key, soul, cmd, args, raw)
|
||||
|
||||
return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Automatische NAS + second-brain synchronisatie."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
log = logging.getLogger("cockpit.auto_ingest")
|
||||
|
||||
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
|
||||
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
|
||||
|
||||
def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
|
||||
try:
|
||||
execute(
|
||||
"INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
|
||||
(source, status, json.dumps(details)),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("ingest log failed: %s", exc)
|
||||
|
||||
|
||||
def _brain_chat_id_for_client(client_id: int) -> int:
|
||||
return -int(client_id)
|
||||
|
||||
|
||||
async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
|
||||
"""Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
|
||||
da.word_count, dl.client_id, c.name AS client_name
|
||||
FROM document_analytics da
|
||||
LEFT JOIN document_links dl ON (
|
||||
da.storage_path = dl.storage_path
|
||||
OR da.storage_path LIKE dl.storage_path || '/%%'
|
||||
) AND dl.is_folder = TRUE
|
||||
LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
|
||||
LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
|
||||
WHERE da.storage_path IS NOT NULL
|
||||
ORDER BY da.analyzed_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 200)),),
|
||||
)
|
||||
synced = 0
|
||||
errors = 0
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
for row in rows:
|
||||
path = row.get("storage_path") or ""
|
||||
if not path:
|
||||
continue
|
||||
client_id = row.get("client_id")
|
||||
chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
|
||||
text = (
|
||||
f"NAS document: {row.get('filename') or path}\n"
|
||||
f"Pad: {path}\nType: {row.get('doc_type')}\n"
|
||||
f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
|
||||
)
|
||||
msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
|
||||
try:
|
||||
r = await client.post(
|
||||
f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"direction": "inbound",
|
||||
"role": "system",
|
||||
"content_type": "document",
|
||||
"content_text": text[:4000],
|
||||
"agent_name": "auto-ingest",
|
||||
"embed": True,
|
||||
"chat_type": "client_brain" if client_id else "nas_corpus",
|
||||
"user_name": row.get("client_name") or "NAS",
|
||||
"content_json": {"storage_path": path, "client_id": client_id},
|
||||
"telegram_message_id": msg_hash,
|
||||
},
|
||||
)
|
||||
if r.status_code < 400:
|
||||
synced += 1
|
||||
else:
|
||||
errors += 1
|
||||
except Exception as exc:
|
||||
log.warning("brain sync %s: %s", path, exc)
|
||||
errors += 1
|
||||
return {"synced": synced, "errors": errors, "candidates": len(rows)}
|
||||
|
||||
|
||||
async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
|
||||
"""Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
|
||||
out: dict[str, Any] = {"ok": True, "steps": {}}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
|
||||
params={"force": "true" if force_scan else "false"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
out["steps"]["nas_scan"] = r.json()
|
||||
except Exception as exc:
|
||||
out["steps"]["nas_scan"] = {"error": str(exc)}
|
||||
brain = await sync_nas_to_brain()
|
||||
out["steps"]["brain_sync"] = brain
|
||||
status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
|
||||
_log_run("auto_sync", status, out)
|
||||
out["last_logged"] = True
|
||||
return out
|
||||
|
||||
|
||||
def last_auto_sync() -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if hasattr(d.get("created_at"), "isoformat"):
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
if isinstance(d.get("details"), str):
|
||||
try:
|
||||
d["details"] = json.loads(d["details"])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import projects as projects_svc
|
||||
|
||||
|
||||
def _serialize_row(row: dict | None) -> dict | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_rows(rows: list) -> list[dict]:
|
||||
return [_serialize_row(r) for r in rows if r]
|
||||
|
||||
|
||||
def get_client_360(client_id: int) -> dict[str, Any]:
|
||||
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
|
||||
if not client:
|
||||
return {"ok": False, "error": "Client not found"}
|
||||
|
||||
projs = projects_svc.list_projects(client_id=client_id, limit=50)
|
||||
links = fetch_all(
|
||||
"""
|
||||
SELECT dl.*, p.name AS project_name
|
||||
FROM document_links dl
|
||||
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
|
||||
WHERE dl.client_id = %s
|
||||
ORDER BY dl.created_at DESC
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
paths = [l["storage_path"] for l in links if l.get("storage_path")]
|
||||
docs: list[dict] = []
|
||||
if paths:
|
||||
placeholders = ",".join(["%s"] * len(paths))
|
||||
docs = fetch_all(
|
||||
f"""
|
||||
SELECT filename, storage_path, doc_type, word_count, sentiment_label,
|
||||
sentiment_compound, analyzed_at, user_labels
|
||||
FROM document_analytics
|
||||
WHERE storage_path IN ({placeholders})
|
||||
OR storage_path LIKE ANY (
|
||||
SELECT dl.storage_path || '/%%' FROM document_links dl
|
||||
WHERE dl.client_id = %s AND dl.is_folder = TRUE
|
||||
)
|
||||
ORDER BY analyzed_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
""",
|
||||
tuple(paths + [client_id]),
|
||||
)
|
||||
sentiment_rows = fetch_all(
|
||||
"""
|
||||
SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
|
||||
FROM document_analytics da
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
GROUP BY sentiment_label
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
top_words = fetch_all(
|
||||
"""
|
||||
SELECT dwc.lemma, SUM(dwc.count) AS total
|
||||
FROM document_word_counts dwc
|
||||
JOIN document_analytics da ON da.storage_path = dwc.storage_path
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
AND NOT dwc.is_stopword
|
||||
GROUP BY dwc.lemma
|
||||
ORDER BY total DESC
|
||||
LIMIT 25
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
deals = fetch_all(
|
||||
"SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
|
||||
(client_id,),
|
||||
)
|
||||
stores = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
|
||||
FROM client_supermarket_links l
|
||||
JOIN supermarkets s ON s.id = l.supermarket_id
|
||||
WHERE l.client_id = %s
|
||||
LIMIT 30
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
trends = fetch_all(
|
||||
"""
|
||||
SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
|
||||
COUNT(*) AS docs,
|
||||
AVG(da.sentiment_compound) AS avg_sentiment,
|
||||
SUM(da.word_count) AS words
|
||||
FROM document_analytics da
|
||||
WHERE da.analyzed_at IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM document_links dl
|
||||
WHERE dl.client_id = %s
|
||||
AND (da.storage_path = dl.storage_path
|
||||
OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
|
||||
)
|
||||
GROUP BY 1
|
||||
ORDER BY 1 DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
for t in trends:
|
||||
if hasattr(t.get("month"), "isoformat"):
|
||||
t["month"] = t["month"].isoformat()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"client": _serialize_row(client),
|
||||
"projects": projs,
|
||||
"links": _serialize_rows(links),
|
||||
"documents": _serialize_rows(docs),
|
||||
"sentiment_breakdown": _serialize_rows(sentiment_rows),
|
||||
"top_words": _serialize_rows(top_words),
|
||||
"deals": _serialize_rows(deals),
|
||||
"stores": _serialize_rows(stores),
|
||||
"monthly_trends": trends,
|
||||
"stats": {
|
||||
"linked_paths": len(links),
|
||||
"documents": len(docs),
|
||||
"projects": len(projs),
|
||||
"stores": len(stores),
|
||||
"deals": len(deals),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: foodlinkk_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
tools-api:
|
||||
build: ./tools-api
|
||||
container_name: foodlinkk_tools_api
|
||||
environment:
|
||||
DB_HOST: foodlinkk_db
|
||||
DB_USER: aissa
|
||||
DB_PASSWORD: Foodlinkk#2026
|
||||
DB_NAME: foodlinkk
|
||||
BACKUP_ROOT: /data/backup-root
|
||||
GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
|
||||
GITEA_SYSOPS_USER: sysops
|
||||
GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
|
||||
GITEA_SYSOPS_NAME: sysops
|
||||
GITEA_BACKUP_BRANCH: main
|
||||
PROXMOX_HOST: 10.4.7.14
|
||||
VM106_IP: 10.4.7.18
|
||||
OLLAMA_URL: http://10.4.7.19:11434
|
||||
OLLAMA_MODEL: qwen3:8b
|
||||
CHROMA_HOST: 10.4.7.19
|
||||
CHROMA_PORT: "8000"
|
||||
DOC_INGEST_URL: http://10.4.7.19:8750
|
||||
BROWSER_USE_URL: http://browser-agent:7790
|
||||
BROWSER_AGENT_URL: http://browser-agent:7790
|
||||
COMFYUI_URL: http://10.4.7.18:8188
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
ports:
|
||||
- "8700:8700"
|
||||
volumes:
|
||||
- /home/aissa/foodlinkk-command-center:/data/backup-root:ro
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
restart: unless-stopped
|
||||
|
||||
cockpit:
|
||||
build: ./cockpit
|
||||
container_name: foodlinkk_cockpit
|
||||
environment:
|
||||
DB_HOST: foodlinkk_db
|
||||
DB_USER: aissa
|
||||
DB_PASSWORD: Foodlinkk#2026
|
||||
DB_NAME: foodlinkk
|
||||
VNC_CDP_URL: http://10.4.7.18:9223
|
||||
OLLAMA_URL: http://10.4.7.19:11434
|
||||
OLLAMA_MODEL: qwen3:8b
|
||||
TOOLS_API_URL: http://tools-api:8700
|
||||
CHROMA_HOST: 10.4.7.19
|
||||
CHROMA_PORT: "8000"
|
||||
DOC_INGEST_URL: http://10.4.7.19:8750
|
||||
BROWSER_AGENT_URL: http://browser-agent:7790
|
||||
COMFYUI_URL: http://10.4.7.18:8188
|
||||
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
|
||||
NAS_CLIENTS_ROOT: /data/nas-clients
|
||||
NAS_SHARE_ROOT: /data/nas-share
|
||||
ports:
|
||||
- "8600:8600"
|
||||
volumes:
|
||||
- /home/aissa/nas-clients:/data/nas-clients
|
||||
- /mnt/synology:/data/nas-share:ro
|
||||
depends_on:
|
||||
- tools-api
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
restart: unless-stopped
|
||||
|
||||
browser-agent:
|
||||
build: ./browser-agent
|
||||
container_name: foodlinkk_browser_agent
|
||||
environment:
|
||||
DB_HOST: foodlinkk_db
|
||||
DB_USER: aissa
|
||||
DB_PASSWORD: Foodlinkk#2026
|
||||
DB_NAME: foodlinkk
|
||||
VNC_CDP_URL: http://10.4.7.18:9223
|
||||
ports:
|
||||
- "7790:7790"
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
restart: unless-stopped
|
||||
shm_size: "1gb"
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:1.21-rootless
|
||||
container_name: foodlinkk_gitea
|
||||
environment:
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__server__ROOT_URL: http://10.4.7.18:3001/
|
||||
GITEA__server__HTTP_PORT: 3001
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__security__SECRET_KEY: foodlinkk-gitea-secret-2026
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- gitea_data:/var/lib/gitea
|
||||
restart: unless-stopped
|
||||
|
||||
email-agent:
|
||||
build: ./email-agent
|
||||
container_name: foodlinkk_email_agent
|
||||
environment:
|
||||
DB_HOST: foodlinkk_db
|
||||
DB_USER: aissa
|
||||
DB_PASSWORD: Foodlinkk#2026
|
||||
DB_NAME: foodlinkk
|
||||
TOOLS_API_URL: http://tools-api:8700
|
||||
SYNC_INTERVAL_SEC: 300
|
||||
ports:
|
||||
- "8801:8801"
|
||||
depends_on:
|
||||
- tools-api
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
restart: unless-stopped
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.0
|
||||
container_name: foodlinkk_prometheus
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
ports:
|
||||
- "9090:9090"
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.1.0
|
||||
container_name: foodlinkk_grafana
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: Foodlinkk#2026
|
||||
GF_SERVER_ROOT_URL: http://10.4.7.18:3002
|
||||
ports:
|
||||
- "3002:3000"
|
||||
depends_on:
|
||||
- prometheus
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
"""Parse Succes Sheet revenue tab — runs in cockpit with NAS mount."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
NAS_SHARE_ROOT = Path(os.getenv("NAS_SHARE_ROOT", "/data/nas-share"))
|
||||
DEFAULT_FILE = os.getenv("CEO_EXCEL_PATH", "Succes Sheet .xlsx")
|
||||
DEFAULT_SHEET = os.getenv("CEO_EXCEL_SHEET", "Projects next steps revenue")
|
||||
|
||||
_FILL_STYLE = {
|
||||
"FF92D050": "green",
|
||||
"FFFF0000": "red",
|
||||
"FFFFC000": "orange",
|
||||
"FFFFFF00": "yellow",
|
||||
"FFBDD7EE": "blue",
|
||||
"FFDDEBF7": "blue",
|
||||
"FF0070C0": "blue",
|
||||
}
|
||||
|
||||
|
||||
def _num(val: Any) -> float | None:
|
||||
if val is None or val == "":
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _txt(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def _cell_fill_style(cell) -> str:
|
||||
try:
|
||||
if not cell or not cell.fill or cell.fill.fill_type != "solid":
|
||||
return "white"
|
||||
rgb = cell.fill.fgColor.rgb if cell.fill.fgColor else None
|
||||
if not rgb or rgb in ("00000000", "FFFFFFFF", "00FFFFFF"):
|
||||
return "white"
|
||||
key = rgb[-8:].upper() if len(rgb) >= 8 else rgb.upper()
|
||||
if key in _FILL_STYLE:
|
||||
return _FILL_STYLE[key]
|
||||
short = key[-6:]
|
||||
for k, v in _FILL_STYLE.items():
|
||||
if k.endswith(short):
|
||||
return v
|
||||
return "white"
|
||||
except Exception:
|
||||
return "white"
|
||||
|
||||
|
||||
def parse_revenue_sheet(
|
||||
rel_path: str = DEFAULT_FILE,
|
||||
sheet_name: str | None = DEFAULT_SHEET,
|
||||
nas_root: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError as exc:
|
||||
return {"ok": False, "error": f"openpyxl not installed: {exc}"}
|
||||
|
||||
root = nas_root or NAS_SHARE_ROOT
|
||||
full = root / rel_path.lstrip("/")
|
||||
if not full.is_file():
|
||||
return {"ok": False, "error": f"File not found: {full}"}
|
||||
|
||||
st = full.stat()
|
||||
wb = load_workbook(full, read_only=False, data_only=True)
|
||||
names = wb.sheetnames
|
||||
target_name = sheet_name
|
||||
if not target_name:
|
||||
for n in names:
|
||||
if "project" in n.lower() and "revenue" in n.lower():
|
||||
target_name = n
|
||||
break
|
||||
if not target_name and len(names) > 2:
|
||||
target_name = names[2]
|
||||
if not target_name:
|
||||
return {"ok": False, "error": "Sheet not found", "sheetnames": names}
|
||||
|
||||
ws = wb[target_name]
|
||||
rows = list(ws.iter_rows(values_only=False))
|
||||
|
||||
goals: dict[str, str] = {"vision_text": "", "horizon_text": "", "mid_text": "", "tagline": ""}
|
||||
if rows:
|
||||
r0 = rows[0]
|
||||
goals["vision_text"] = _txt(r0[0].value if len(r0) > 0 else "")
|
||||
goals["horizon_text"] = _txt(r0[1].value if len(r0) > 1 else "")
|
||||
goals["mid_text"] = _txt(r0[2].value if len(r0) > 2 else "")
|
||||
goals["tagline"] = _txt(r0[4].value if len(r0) > 4 else (_txt(r0[3].value if len(r0) > 3 else "")))
|
||||
|
||||
projects: list[dict[str, Any]] = []
|
||||
sort_order = 0
|
||||
for idx, row in enumerate(rows):
|
||||
if idx <= 1:
|
||||
continue
|
||||
cells = list(row) if row else []
|
||||
name = _txt(cells[1].value if len(cells) > 1 else "")
|
||||
if not name:
|
||||
continue
|
||||
margin_month = _num(cells[2].value if len(cells) > 2 else None)
|
||||
margin_year = _num(cells[3].value if len(cells) > 3 else None)
|
||||
next_steps = _txt(cells[4].value if len(cells) > 4 else "")
|
||||
target_extra = _num(cells[5].value if len(cells) > 5 else None)
|
||||
row_style = _cell_fill_style(cells[1] if len(cells) > 1 else None)
|
||||
category = "deal" if margin_month is not None or margin_year is not None else "initiative"
|
||||
if row_style == "yellow" or re.search(r"foodlinkk|linknbit|subsid|total earnings|loonkosten", name, re.I):
|
||||
category = "strategic"
|
||||
projects.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"margin_month": margin_month,
|
||||
"margin_year": margin_year,
|
||||
"target_revenue": target_extra or margin_year,
|
||||
"next_steps": next_steps,
|
||||
"status": "active",
|
||||
"sort_order": sort_order,
|
||||
"source_row": idx,
|
||||
"row_style": row_style,
|
||||
}
|
||||
)
|
||||
sort_order += 1
|
||||
|
||||
wb.close()
|
||||
return {
|
||||
"ok": True,
|
||||
"source_file": rel_path,
|
||||
"sheet_name": target_name,
|
||||
"sheetnames": names,
|
||||
"file_mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"file_size": st.st_size,
|
||||
"goals": goals,
|
||||
"projects": projects,
|
||||
"parsed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"project_count": len(projects),
|
||||
}
|
||||
+141
-10
@@ -6,6 +6,8 @@ from app.config import settings
|
||||
from app.db import execute, fetch_one
|
||||
from app.services import ollama
|
||||
from app.services import packaging_agent
|
||||
from app.services import webbuilder_agent
|
||||
from app.services import voice_export_actions
|
||||
|
||||
AGENTS: dict[str, dict[str, str]] = {
|
||||
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,),
|
||||
)
|
||||
+517
-50
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import execute, fetch_one, get_connection
|
||||
from app.db import execute, fetch_all, fetch_one, get_connection
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
|
||||
|
||||
@@ -45,6 +46,59 @@ def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_parse_info(
|
||||
html: str,
|
||||
text: str,
|
||||
title: str,
|
||||
final_url: str,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
soup = BeautifulSoup(html or "", "html.parser")
|
||||
headings: list[str] = []
|
||||
for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
|
||||
t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
|
||||
if t:
|
||||
headings.append(t[:140])
|
||||
|
||||
links_sample: list[dict[str, str]] = []
|
||||
seen_hrefs: set[str] = set()
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = (a.get("href") or "").strip()
|
||||
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
|
||||
continue
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(base_url or final_url, href)
|
||||
if href in seen_hrefs:
|
||||
continue
|
||||
seen_hrefs.add(href)
|
||||
label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
|
||||
links_sample.append({"href": href, "label": label or href})
|
||||
if len(links_sample) >= 10:
|
||||
break
|
||||
|
||||
words = len(text.split()) if text else 0
|
||||
excerpt = ""
|
||||
if text:
|
||||
excerpt = text[:320] + ("…" if len(text) > 320 else "")
|
||||
|
||||
meta_desc = ""
|
||||
md = soup.find("meta", attrs={"name": "description"})
|
||||
if md and md.get("content"):
|
||||
meta_desc = str(md["content"]).strip()[:240]
|
||||
|
||||
return {
|
||||
"title": title or "(geen titel)",
|
||||
"final_url": final_url,
|
||||
"word_count": words,
|
||||
"char_count": len(text or ""),
|
||||
"excerpt": excerpt,
|
||||
"meta_description": meta_desc,
|
||||
"headings": headings,
|
||||
"links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
|
||||
"links_sample": links_sample,
|
||||
}
|
||||
|
||||
|
||||
def get_page_hash(url: str) -> str | None:
|
||||
fetched = _fetch_page(url)
|
||||
if not fetched:
|
||||
@@ -53,20 +107,31 @@ def get_page_hash(url: str) -> str | None:
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
|
||||
import json
|
||||
def _save_snapshot(
|
||||
site_id: int,
|
||||
url: str,
|
||||
final_url: str,
|
||||
title: str,
|
||||
text: str,
|
||||
html: str,
|
||||
parse_info: dict[str, Any] | None = None,
|
||||
) -> int | None:
|
||||
parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
|
||||
metadata = {"source": "monitor", "parse": parse_info}
|
||||
links_json = json.dumps(parse_info.get("links_sample") or [])
|
||||
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
|
||||
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
|
||||
site_id=EXCLUDED.site_id, crawled_at=NOW()
|
||||
site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
|
||||
links=EXCLUDED.links, crawled_at=NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@@ -76,7 +141,8 @@ def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str
|
||||
text[:50000],
|
||||
html[:100000],
|
||||
site_id,
|
||||
json.dumps({"source": "monitor"}),
|
||||
json.dumps(metadata),
|
||||
links_json,
|
||||
),
|
||||
)
|
||||
page_id = cur.fetchone()[0]
|
||||
@@ -125,7 +191,8 @@ def add_site(url: str, name: str) -> dict:
|
||||
)
|
||||
site_id = cur.fetchone()[0]
|
||||
if fetched:
|
||||
_save_snapshot(site_id, url, final_url, title, text, html)
|
||||
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
||||
_save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
||||
row = fetch_one(
|
||||
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
|
||||
(site_id,),
|
||||
@@ -142,61 +209,461 @@ def remove_site(site_id: int, soft: bool = True) -> None:
|
||||
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
|
||||
|
||||
|
||||
def trigger_crawl(site_id: int | None = None) -> dict:
|
||||
try:
|
||||
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
|
||||
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
|
||||
return {"ok": True, "method": "worker"}
|
||||
except Exception:
|
||||
pass
|
||||
def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
|
||||
site_id = int(site["id"])
|
||||
url = site["url"]
|
||||
name = site.get("name") or url
|
||||
result: dict[str, Any] = {
|
||||
"site_id": site_id,
|
||||
"url": url,
|
||||
"name": name,
|
||||
"status": "ERROR",
|
||||
"changed": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
from app.db import fetch_all
|
||||
|
||||
if site_id:
|
||||
row = fetch_one(
|
||||
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
|
||||
(site_id,),
|
||||
)
|
||||
sites = [row] if row else []
|
||||
else:
|
||||
sites = fetch_all(
|
||||
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
|
||||
)
|
||||
|
||||
changed = 0
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for site in sites:
|
||||
fetched = _fetch_page(site["url"])
|
||||
fetched = _fetch_page(url)
|
||||
if not fetched:
|
||||
cur.execute(
|
||||
msg = f"Kan {url} niet bereiken"
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
|
||||
(site_id, "ERROR", msg),
|
||||
)
|
||||
continue
|
||||
result["error"] = msg
|
||||
return result
|
||||
|
||||
final_url, title, text, html = fetched
|
||||
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
||||
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
old_hash = site.get("last_hash")
|
||||
if old_hash and old_hash != new_hash:
|
||||
cur.execute(
|
||||
changed = bool(old_hash and old_hash != new_hash)
|
||||
|
||||
if changed:
|
||||
execute(
|
||||
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
|
||||
(site["id"], old_hash, new_hash),
|
||||
(site_id, old_hash, new_hash),
|
||||
)
|
||||
cur.execute(
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
|
||||
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
|
||||
)
|
||||
changed += 1
|
||||
result["status"] = "CHANGE"
|
||||
else:
|
||||
cur.execute(
|
||||
execute(
|
||||
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
||||
(site["id"], "OK", f"Crawl OK — {title}"),
|
||||
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
|
||||
)
|
||||
cur.execute(
|
||||
result["status"] = "OK"
|
||||
|
||||
execute(
|
||||
"""
|
||||
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
|
||||
""",
|
||||
(new_hash, title, site["id"]),
|
||||
(new_hash, title, site_id),
|
||||
)
|
||||
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
|
||||
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
|
||||
snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
||||
|
||||
result.update(parse_info)
|
||||
result["changed"] = changed
|
||||
result["snapshot_id"] = snapshot_id
|
||||
return result
|
||||
|
||||
|
||||
def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
|
||||
if site_id:
|
||||
row = fetch_one(
|
||||
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
|
||||
(site_id,),
|
||||
)
|
||||
sites = [dict(row)] if row else []
|
||||
else:
|
||||
sites = [
|
||||
dict(r)
|
||||
for r in fetch_all(
|
||||
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
|
||||
)
|
||||
]
|
||||
|
||||
if not sites:
|
||||
return {
|
||||
"ok": True,
|
||||
"method": "inline",
|
||||
"sites": 0,
|
||||
"changed": 0,
|
||||
"errors": 0,
|
||||
"results": [],
|
||||
"message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
|
||||
}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
changed = 0
|
||||
errors = 0
|
||||
for site in sites:
|
||||
row = _crawl_one_site(site)
|
||||
results.append(row)
|
||||
if row.get("status") == "ERROR":
|
||||
errors += 1
|
||||
if row.get("changed"):
|
||||
changed += 1
|
||||
|
||||
return {
|
||||
"ok": errors < len(sites),
|
||||
"method": "inline",
|
||||
"sites": len(sites),
|
||||
"changed": changed,
|
||||
"errors": errors,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
limit = max(1, min(limit, 50))
|
||||
if site_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id = %s
|
||||
ORDER BY cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(site_id, limit),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
ORDER BY cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
meta = item.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
parse = (meta or {}).get("parse") or {}
|
||||
content = item.get("content") or ""
|
||||
if not parse.get("excerpt") and content:
|
||||
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
||||
if not parse.get("word_count") and content:
|
||||
parse["word_count"] = len(str(content).split())
|
||||
links = item.get("links") or []
|
||||
if isinstance(links, str):
|
||||
try:
|
||||
links = json.loads(links)
|
||||
except Exception:
|
||||
links = []
|
||||
if not parse.get("links_sample") and links:
|
||||
parse["links_sample"] = links
|
||||
crawled = item.get("crawled_at")
|
||||
if crawled is not None and hasattr(crawled, "isoformat"):
|
||||
item["crawled_at"] = crawled.isoformat()
|
||||
out.append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"site_name": item.get("site_name"),
|
||||
"url": item.get("url"),
|
||||
"final_url": item.get("final_url"),
|
||||
"title": item.get("title") or parse.get("title"),
|
||||
"crawled_at": item.get("crawled_at"),
|
||||
"word_count": parse.get("word_count", 0),
|
||||
"excerpt": parse.get("excerpt", ""),
|
||||
"meta_description": parse.get("meta_description", ""),
|
||||
"headings": parse.get("headings") or [],
|
||||
"links_count": parse.get("links_count", len(links)),
|
||||
"links_sample": parse.get("links_sample") or links[:10],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
NL_STOPWORDS = frozenset(
|
||||
"""
|
||||
de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
|
||||
kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
|
||||
heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
|
||||
for on at by from with about into through during before after above below between under again
|
||||
further then once here there when where why how all each few more most other some such no nor
|
||||
not only own same so than too very just don should now naar website home pagina menu contact
|
||||
service cookie cookies privacy login inloggen registreren meer lees read click klik
|
||||
""".split()
|
||||
)
|
||||
|
||||
|
||||
def _tokens(text: str, min_len: int = 4) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
|
||||
return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
|
||||
|
||||
|
||||
def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
|
||||
item = dict(row)
|
||||
meta = item.get("metadata") or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
parse = (meta or {}).get("parse") or {}
|
||||
content = str(item.get("content") or "")
|
||||
if not parse.get("excerpt") and content:
|
||||
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
||||
if not parse.get("word_count") and content:
|
||||
parse["word_count"] = len(content.split())
|
||||
links = item.get("links") or []
|
||||
if isinstance(links, str):
|
||||
try:
|
||||
links = json.loads(links)
|
||||
except Exception:
|
||||
links = []
|
||||
if not parse.get("links_sample") and links:
|
||||
parse["links_sample"] = links
|
||||
crawled = item.get("crawled_at")
|
||||
if crawled is not None and hasattr(crawled, "isoformat"):
|
||||
crawled = crawled.isoformat()
|
||||
content_read = content[:content_limit]
|
||||
if len(content) > content_limit:
|
||||
content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"site_name": item.get("site_name"),
|
||||
"url": item.get("url"),
|
||||
"final_url": item.get("final_url"),
|
||||
"title": item.get("title") or parse.get("title"),
|
||||
"crawled_at": crawled,
|
||||
"word_count": parse.get("word_count", 0),
|
||||
"char_count": parse.get("char_count", len(content)),
|
||||
"excerpt": parse.get("excerpt", ""),
|
||||
"meta_description": parse.get("meta_description", ""),
|
||||
"headings": parse.get("headings") or [],
|
||||
"links_count": parse.get("links_count", len(links)),
|
||||
"links_sample": parse.get("links_sample") or links[:15],
|
||||
"content_read": content_read,
|
||||
"content_length": len(content),
|
||||
"has_full_content": len(content) > 0,
|
||||
}
|
||||
|
||||
|
||||
def get_parse_page(page_id: int) -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.id = %s
|
||||
""",
|
||||
(page_id,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
page = _normalize_page_row(dict(row), content_limit=50000)
|
||||
page["content_full"] = str(dict(row).get("content") or "")
|
||||
return page
|
||||
|
||||
|
||||
def build_parse_intelligence(
|
||||
site_id: int | None = None,
|
||||
query: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate parsed pages for analysis — hype terms, trends, readable content."""
|
||||
limit = max(1, min(limit, 100))
|
||||
if site_id:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT DISTINCT ON (cp.site_id)
|
||||
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id = %s
|
||||
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
else:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT DISTINCT ON (cp.site_id)
|
||||
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
||||
cp.crawled_at, cp.site_id, ms.name AS site_name
|
||||
FROM crawled_pages cp
|
||||
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
||||
WHERE cp.site_id IS NOT NULL
|
||||
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
pages = [_normalize_page_row(dict(r)) for r in rows]
|
||||
q = (query or "").strip().lower()
|
||||
if q:
|
||||
pages = [
|
||||
p
|
||||
for p in pages
|
||||
if q in (p.get("title") or "").lower()
|
||||
or q in (p.get("content_read") or "").lower()
|
||||
or q in (p.get("excerpt") or "").lower()
|
||||
or any(q in h.lower() for h in p.get("headings") or [])
|
||||
]
|
||||
|
||||
changed_site_ids: set[int] = set()
|
||||
recent_changes: list[dict[str, Any]] = []
|
||||
try:
|
||||
change_rows = fetch_all(
|
||||
"""
|
||||
SELECT pc.site_id, pc.changed_at, ms.name, ms.url
|
||||
FROM page_changes pc
|
||||
JOIN monitored_sites ms ON ms.id = pc.site_id
|
||||
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
|
||||
ORDER BY pc.changed_at DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
)
|
||||
for cr in change_rows:
|
||||
sid = int(cr["site_id"])
|
||||
changed_site_ids.add(sid)
|
||||
ts = cr.get("changed_at")
|
||||
if ts is not None and hasattr(ts, "isoformat"):
|
||||
ts = ts.isoformat()
|
||||
recent_changes.append(
|
||||
{
|
||||
"site_id": sid,
|
||||
"site_name": cr.get("name"),
|
||||
"url": cr.get("url"),
|
||||
"changed_at": ts,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
term_scores: dict[str, dict[str, Any]] = {}
|
||||
heading_counts: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bump_term(term: str, site_name: str, weight: int = 1) -> None:
|
||||
if len(term) < 3:
|
||||
return
|
||||
bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
|
||||
bucket["score"] += weight
|
||||
if site_name:
|
||||
bucket["sites"].add(site_name)
|
||||
|
||||
for page in pages:
|
||||
site_name = page.get("site_name") or str(page.get("site_id") or "")
|
||||
for tok in _tokens(page.get("title") or "", min_len=3):
|
||||
bump_term(tok, site_name, 3)
|
||||
for h in page.get("headings") or []:
|
||||
hnorm = re.sub(r"\s+", " ", h.strip())[:80]
|
||||
if len(hnorm) < 3:
|
||||
continue
|
||||
hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
|
||||
hc["count"] += 1
|
||||
hc["sites"].add(site_name)
|
||||
for tok in _tokens(h, min_len=3):
|
||||
bump_term(tok, site_name, 4)
|
||||
for tok in _tokens(page.get("content_read") or ""):
|
||||
bump_term(tok, site_name, 1)
|
||||
for link in page.get("links_sample") or []:
|
||||
for tok in _tokens(link.get("label") or "", min_len=3):
|
||||
bump_term(tok, site_name, 2)
|
||||
|
||||
hype_terms: list[dict[str, Any]] = []
|
||||
for term, data in term_scores.items():
|
||||
if data["score"] < 4:
|
||||
continue
|
||||
sites_list = sorted(data["sites"])
|
||||
hype_terms.append(
|
||||
{
|
||||
"term": term,
|
||||
"score": data["score"],
|
||||
"site_count": len(sites_list),
|
||||
"sites": sites_list[:5],
|
||||
"cross_site": len(sites_list) >= 2,
|
||||
}
|
||||
)
|
||||
hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
|
||||
hype_terms = hype_terms[:40]
|
||||
|
||||
heading_trends = []
|
||||
for _key, data in heading_counts.items():
|
||||
if data["count"] < 1:
|
||||
continue
|
||||
heading_trends.append(
|
||||
{
|
||||
"label": data["label"],
|
||||
"count": data["count"],
|
||||
"sites": sorted(data["sites"])[:6],
|
||||
"cross_site": len(data["sites"]) >= 2,
|
||||
}
|
||||
)
|
||||
heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
|
||||
heading_trends = heading_trends[:25]
|
||||
|
||||
top_term_set = {t["term"] for t in hype_terms[:15]}
|
||||
food_signals = frozenset(
|
||||
"halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
|
||||
"groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
|
||||
"supermarkt retail assortiment prijs private label merk".split()
|
||||
)
|
||||
|
||||
for page in pages:
|
||||
signals: list[str] = []
|
||||
wc = int(page.get("word_count") or 0)
|
||||
sid = page.get("site_id")
|
||||
if wc >= 1500:
|
||||
signals.append("Rijke pagina — veel te analyseren")
|
||||
elif wc >= 400:
|
||||
signals.append("Normale pagina-dichtheid")
|
||||
if sid in changed_site_ids:
|
||||
signals.append("Recent gewijzigd — mogelijke hype/shift")
|
||||
|
||||
page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
|
||||
matched_hype = [t for t in top_term_set if t in page_terms]
|
||||
food_hits = [t for t in page_terms if t in food_signals]
|
||||
for t in matched_hype[:4]:
|
||||
signals.append(f"Trend-term: {t}")
|
||||
for t in food_hits[:3]:
|
||||
if f"Trend-term: {t}" not in signals:
|
||||
signals.append(f"Food-signaal: {t}")
|
||||
|
||||
if page.get("meta_description"):
|
||||
signals.append("SEO meta beschikbaar")
|
||||
|
||||
page["signals"] = signals[:8]
|
||||
page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
|
||||
page["recently_changed"] = sid in changed_site_ids
|
||||
|
||||
pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
|
||||
|
||||
total_words = sum(int(p.get("word_count") or 0) for p in pages)
|
||||
return {
|
||||
"summary": {
|
||||
"pages": len(pages),
|
||||
"total_words": total_words,
|
||||
"themes_detected": len(hype_terms),
|
||||
"headings_unique": len(heading_trends),
|
||||
"changes_7d": len(recent_changes),
|
||||
"query": q or None,
|
||||
},
|
||||
"hype_terms": hype_terms,
|
||||
"heading_trends": heading_trends,
|
||||
"recent_changes": recent_changes[:12],
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ async def generate(prompt: str, system: str | None = None, timeout: float = 300.
|
||||
return await chat_messages(messages, timeout=timeout)
|
||||
|
||||
|
||||
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
|
||||
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0, model: str | None = None) -> str:
|
||||
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
|
||||
payload = {
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"model": model or settings.OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"think": False,
|
||||
"stream": False,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.12
|
||||
psycopg2-binary==2.9.9
|
||||
httpx==0.27.2
|
||||
websockets==13.1
|
||||
beautifulsoup4==4.12.3
|
||||
textblob==0.18.0.post0
|
||||
openpyxl>=3.1.0
|
||||
tweepy==4.15.0
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Revenue Cockpit — DB operations, import, tracking, agent tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import agent_integration
|
||||
from app.services.excel_import import DEFAULT_FILE, DEFAULT_SHEET, parse_revenue_sheet
|
||||
|
||||
|
||||
def fetch_excel_parse(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
|
||||
result = parse_revenue_sheet(path, sheet)
|
||||
if not result.get("ok"):
|
||||
raise RuntimeError(result.get("error") or "Excel parse failed")
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_excel_parse_async(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
|
||||
return fetch_excel_parse(path, sheet)
|
||||
|
||||
|
||||
def _ser(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def _log_change(entity_type: str, entity_id: int, field: str, old: Any, new: Any, by: str = "ceo") -> None:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO revenue_change_log (entity_type, entity_id, field_name, old_value, new_value, changed_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(entity_type, entity_id, field, str(old) if old is not None else None, str(new) if new is not None else None, by),
|
||||
)
|
||||
|
||||
|
||||
def import_from_parsed(parsed: dict[str, Any], imported_by: str = "ceo", replace: bool = True) -> dict[str, Any]:
|
||||
if replace:
|
||||
execute("DELETE FROM revenue_objectives WHERE project_id IN (SELECT id FROM revenue_projects)")
|
||||
execute("DELETE FROM revenue_projects")
|
||||
execute("UPDATE revenue_cockpit_goals SET is_active = FALSE WHERE is_active = TRUE")
|
||||
|
||||
goals = parsed.get("goals") or {}
|
||||
g = fetch_one(
|
||||
"""
|
||||
INSERT INTO revenue_cockpit_goals (vision_text, horizon_text, mid_text, tagline, is_active)
|
||||
VALUES (%s, %s, %s, %s, TRUE)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
goals.get("vision_text", ""),
|
||||
goals.get("horizon_text", ""),
|
||||
goals.get("mid_text", ""),
|
||||
goals.get("tagline", ""),
|
||||
),
|
||||
)
|
||||
|
||||
count = 0
|
||||
for p in parsed.get("projects") or []:
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO revenue_projects
|
||||
(name, category, margin_month, margin_year, target_revenue, next_steps, status, sort_order, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
p["name"],
|
||||
p.get("category", "deal"),
|
||||
p.get("margin_month"),
|
||||
p.get("margin_year"),
|
||||
p.get("target_revenue"),
|
||||
p.get("next_steps"),
|
||||
p.get("status", "active"),
|
||||
p.get("sort_order", count),
|
||||
json.dumps({
|
||||
"source_row": p.get("source_row"),
|
||||
"row_style": p.get("row_style", "white"),
|
||||
"imported": True,
|
||||
}),
|
||||
),
|
||||
)
|
||||
pid = row["id"] if row else None
|
||||
if pid and p.get("next_steps"):
|
||||
for i, line in enumerate([ln.strip() for ln in p["next_steps"].split("\n") if ln.strip()][:5]):
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO revenue_objectives (project_id, title, description, status, sort_order)
|
||||
VALUES (%s, %s, %s, 'open', %s)
|
||||
""",
|
||||
(pid, line[:255], p["next_steps"] if i == 0 else None, i),
|
||||
)
|
||||
count += 1
|
||||
|
||||
run = fetch_one(
|
||||
"""
|
||||
INSERT INTO revenue_import_runs (source_file, sheet_name, rows_imported, goals_imported, imported_by, metadata)
|
||||
VALUES (%s, %s, %s, TRUE, %s, %s::jsonb)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
parsed.get("source_file", ""),
|
||||
parsed.get("sheet_name", ""),
|
||||
count,
|
||||
imported_by,
|
||||
json.dumps({"parsed_at": parsed.get("parsed_at"), "file_mtime": parsed.get("file_mtime")}),
|
||||
),
|
||||
)
|
||||
take_snapshot()
|
||||
return {"ok": True, "projects_imported": count, "goals": _ser(g), "import_run": _ser(run)}
|
||||
|
||||
|
||||
def get_active_goals() -> dict[str, Any] | None:
|
||||
return _ser(fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1"))
|
||||
|
||||
|
||||
def update_goals(data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
|
||||
old = fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1")
|
||||
if not old:
|
||||
return None
|
||||
fields = ("vision_text", "horizon_text", "mid_text", "tagline")
|
||||
sets, params = [], []
|
||||
for k in fields:
|
||||
if k in data:
|
||||
sets.append(f"{k} = %s")
|
||||
params.append(data[k])
|
||||
if str(old.get(k)) != str(data[k]):
|
||||
_log_change("goals", old["id"], k, old.get(k), data[k], changed_by)
|
||||
if not sets:
|
||||
return get_active_goals()
|
||||
params.append(old["id"])
|
||||
execute(
|
||||
f"UPDATE revenue_cockpit_goals SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s",
|
||||
tuple(params),
|
||||
)
|
||||
return get_active_goals()
|
||||
|
||||
|
||||
def list_projects(status: str | None = None) -> list[dict[str, Any]]:
|
||||
clauses, params = [], []
|
||||
if status:
|
||||
clauses.append("status = %s")
|
||||
params.append(status)
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM revenue_objectives o WHERE o.project_id = p.id AND o.status = 'open') AS open_objectives,
|
||||
(SELECT COUNT(*) FROM agent_tasks t WHERE t.revenue_project_id = p.id AND t.status NOT IN ('completed','cancelled')) AS open_tasks
|
||||
FROM revenue_projects p
|
||||
{where}
|
||||
ORDER BY p.sort_order ASC, p.name ASC
|
||||
""",
|
||||
tuple(params) if params else None,
|
||||
)
|
||||
return [_ser({**r, "row_style": _infer_row_style(r.get("name"), r.get("margin_month"), r.get("metadata"))}) for r in rows]
|
||||
|
||||
|
||||
def get_project(project_id: int) -> dict[str, Any] | None:
|
||||
p = _ser(fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,)))
|
||||
if not p:
|
||||
return None
|
||||
p["objectives"] = [_ser(r) for r in fetch_all(
|
||||
"SELECT * FROM revenue_objectives WHERE project_id = %s ORDER BY sort_order, id",
|
||||
(project_id,),
|
||||
)]
|
||||
p["tasks"] = [_ser(r) for r in fetch_all(
|
||||
"""
|
||||
SELECT * FROM agent_tasks WHERE revenue_project_id = %s
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
""",
|
||||
(project_id,),
|
||||
)]
|
||||
p["history"] = [_ser(r) for r in fetch_all(
|
||||
"""
|
||||
SELECT * FROM revenue_change_log
|
||||
WHERE (entity_type = 'project' AND entity_id = %s)
|
||||
OR (entity_type = 'objective' AND entity_id IN (
|
||||
SELECT id FROM revenue_objectives WHERE project_id = %s))
|
||||
ORDER BY created_at DESC LIMIT 30
|
||||
""",
|
||||
(project_id, project_id),
|
||||
)]
|
||||
p["row_style"] = _infer_row_style(p.get("name"), p.get("margin_month"), p.get("metadata"))
|
||||
return p
|
||||
|
||||
|
||||
def set_project_row_style(project_id: int, row_style: str) -> None:
|
||||
old = fetch_one("SELECT metadata FROM revenue_projects WHERE id = %s", (project_id,))
|
||||
meta = dict(old.get("metadata") or {}) if old else {}
|
||||
meta["row_style"] = row_style
|
||||
execute(
|
||||
"UPDATE revenue_projects SET metadata = %s::jsonb, updated_at = NOW() WHERE id = %s",
|
||||
(json.dumps(meta), project_id),
|
||||
)
|
||||
|
||||
|
||||
def update_project(project_id: int, data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
|
||||
old = fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,))
|
||||
if not old:
|
||||
return None
|
||||
fields = {
|
||||
"name": data.get("name"),
|
||||
"category": data.get("category"),
|
||||
"margin_month": data.get("margin_month"),
|
||||
"margin_year": data.get("margin_year"),
|
||||
"target_revenue": data.get("target_revenue"),
|
||||
"next_steps": data.get("next_steps"),
|
||||
"status": data.get("status"),
|
||||
"priority": data.get("priority"),
|
||||
}
|
||||
sets, params = [], []
|
||||
for k, v in fields.items():
|
||||
if k in data:
|
||||
sets.append(f"{k} = %s")
|
||||
params.append(v)
|
||||
if str(old.get(k)) != str(v):
|
||||
_log_change("project", project_id, k, old.get(k), v, changed_by)
|
||||
if not sets:
|
||||
return get_project(project_id)
|
||||
params.append(project_id)
|
||||
execute(f"UPDATE revenue_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
|
||||
return get_project(project_id)
|
||||
|
||||
|
||||
def _infer_row_style(name: str, margin_month: Any, metadata: Any) -> str:
|
||||
if isinstance(metadata, dict) and metadata.get("row_style"):
|
||||
return str(metadata["row_style"])
|
||||
n = (name or "").lower()
|
||||
if "foodlinkk food marketing" in n or "total earnings" in n or "loonkosten aissa" in n:
|
||||
return "yellow"
|
||||
if "foodservice" in n or n.strip() == "dirk":
|
||||
return "orange"
|
||||
if margin_month is not None and margin_month > 0:
|
||||
return "green"
|
||||
if any(x in n for x in ("plus", "vomar", "hoogvliet", "deka", "spar", "doner palace", "zakat")):
|
||||
return "red"
|
||||
return "white"
|
||||
|
||||
|
||||
def create_objective(project_id: int, title: str, description: str | None = None, priority: str = "normal") -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO revenue_objectives (project_id, title, description, priority)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(project_id, title.strip(), description, priority),
|
||||
)
|
||||
_log_change("objective", row["id"], "created", None, title, "ceo")
|
||||
return _ser(row) or {}
|
||||
|
||||
|
||||
def update_objective(objective_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
old = fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,))
|
||||
if not old:
|
||||
return None
|
||||
for k in ("title", "description", "status", "priority", "due_date"):
|
||||
if k in data:
|
||||
execute(
|
||||
f"UPDATE revenue_objectives SET {k} = %s, updated_at = NOW() WHERE id = %s",
|
||||
(data[k], objective_id),
|
||||
)
|
||||
_log_change("objective", objective_id, k, old.get(k), data[k])
|
||||
return _ser(fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,)))
|
||||
|
||||
|
||||
def dashboard_stats() -> dict[str, Any]:
|
||||
goals = get_active_goals()
|
||||
totals = fetch_one(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active_projects,
|
||||
COALESCE(SUM(margin_year) FILTER (WHERE status = 'active'), 0) AS total_margin_year,
|
||||
COALESCE(SUM(margin_month) FILTER (WHERE status = 'active'), 0) AS total_margin_month,
|
||||
COUNT(*) FILTER (WHERE category = 'deal' AND status = 'active') AS active_deals
|
||||
FROM revenue_projects
|
||||
"""
|
||||
) or {}
|
||||
open_obj = fetch_one("SELECT COUNT(*) AS n FROM revenue_objectives WHERE status = 'open'") or {"n": 0}
|
||||
open_tasks = fetch_one(
|
||||
"SELECT COUNT(*) AS n FROM agent_tasks WHERE status NOT IN ('completed','cancelled') AND revenue_project_id IS NOT NULL"
|
||||
) or {"n": 0}
|
||||
prev = fetch_one(
|
||||
"SELECT * FROM revenue_snapshots WHERE snapshot_date < CURRENT_DATE ORDER BY snapshot_date DESC LIMIT 1"
|
||||
)
|
||||
return {
|
||||
"goals": goals,
|
||||
"active_projects": int(totals.get("active_projects") or 0),
|
||||
"active_deals": int(totals.get("active_deals") or 0),
|
||||
"total_margin_year": float(totals.get("total_margin_year") or 0),
|
||||
"total_margin_month": float(totals.get("total_margin_month") or 0),
|
||||
"open_objectives": int(open_obj.get("n") or 0),
|
||||
"open_agent_tasks": int(open_tasks.get("n") or 0),
|
||||
"previous_snapshot": _ser(prev),
|
||||
}
|
||||
|
||||
|
||||
def take_snapshot() -> dict[str, Any]:
|
||||
stats = dashboard_stats()
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO revenue_snapshots
|
||||
(snapshot_date, total_margin_year, total_margin_month, active_projects, open_objectives, open_agent_tasks, payload)
|
||||
VALUES (CURRENT_DATE, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (snapshot_date) DO UPDATE SET
|
||||
total_margin_year = EXCLUDED.total_margin_year,
|
||||
total_margin_month = EXCLUDED.total_margin_month,
|
||||
active_projects = EXCLUDED.active_projects,
|
||||
open_objectives = EXCLUDED.open_objectives,
|
||||
open_agent_tasks = EXCLUDED.open_agent_tasks,
|
||||
payload = EXCLUDED.payload,
|
||||
created_at = NOW()
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
stats["total_margin_year"],
|
||||
stats["total_margin_month"],
|
||||
stats["active_projects"],
|
||||
stats["open_objectives"],
|
||||
stats["open_agent_tasks"],
|
||||
json.dumps({"goals_id": (stats.get("goals") or {}).get("id")}),
|
||||
),
|
||||
)
|
||||
return _ser(row) or {}
|
||||
|
||||
|
||||
def list_snapshots(limit: int = 90) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"SELECT * FROM revenue_snapshots ORDER BY snapshot_date DESC LIMIT %s",
|
||||
(max(1, min(limit, 365)),),
|
||||
)
|
||||
return [_ser(r) for r in rows]
|
||||
|
||||
|
||||
def assign_agent_task(
|
||||
project_id: int,
|
||||
agent_name: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
objective_id: int | None = None,
|
||||
priority: str = "normal",
|
||||
delegate_herman: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
project = fetch_one("SELECT name FROM revenue_projects WHERE id = %s", (project_id,))
|
||||
if not project:
|
||||
raise ValueError("Project not found")
|
||||
agent = agent_name.strip().lower()
|
||||
desc = description or ""
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO agent_tasks
|
||||
(agent_name, title, description, status, priority, assigned_by, revenue_project_id, revenue_objective_id, source)
|
||||
VALUES (%s, %s, %s, 'pending', %s, 'ceo', %s, %s, 'revenue_cockpit')
|
||||
RETURNING *
|
||||
""",
|
||||
(agent, title.strip(), desc, priority, project_id, objective_id),
|
||||
)
|
||||
task = _ser(row) or {}
|
||||
body = f"Revenue Cockpit · {project['name']}: {title}"
|
||||
if desc:
|
||||
body += f"\n{desc}"
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata, related_table, related_id)
|
||||
VALUES (%s, 'revenue_cockpit', 'task_assigned', %s, %s, 'pending', 'revenue_cockpit', %s::jsonb, 'agent_tasks', %s)
|
||||
""",
|
||||
(
|
||||
agent,
|
||||
title.strip(),
|
||||
body,
|
||||
json.dumps({"project_id": project_id, "objective_id": objective_id, "task_id": task.get("id")}),
|
||||
task.get("id"),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if delegate_herman and agent != "herman":
|
||||
try:
|
||||
agent_integration.create_handoff(
|
||||
"ceo",
|
||||
agent,
|
||||
handoff_type="task",
|
||||
payload={"task_id": task.get("id"), "project_id": project_id, "title": title},
|
||||
status="pending",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_log_change("project", project_id, "agent_task", None, title, "ceo")
|
||||
return task
|
||||
|
||||
|
||||
def list_agent_tasks(limit: int = 50) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT t.*, p.name AS project_name
|
||||
FROM agent_tasks t
|
||||
LEFT JOIN revenue_projects p ON p.id = t.revenue_project_id
|
||||
WHERE t.revenue_project_id IS NOT NULL
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 200)),),
|
||||
)
|
||||
return [_ser(r) for r in rows]
|
||||
|
||||
|
||||
async def delegate_via_herman(project_id: int, message: str) -> dict[str, Any]:
|
||||
from app.services import herman as herman_service
|
||||
|
||||
project = get_project(project_id)
|
||||
if not project:
|
||||
raise ValueError("Project not found")
|
||||
prompt = f"[Revenue Cockpit · {project['name']}] {message}"
|
||||
result = await herman_service.chat(prompt)
|
||||
return {"ok": True, "result": result, "project_id": project_id}
|
||||
@@ -0,0 +1,594 @@
|
||||
"""Voice/browser/Telegram command router: export intel, webbuilder/agy, bevestiging + UI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services import webbuilder_agent
|
||||
|
||||
SESSION_TTL_SEC = 3600
|
||||
_pending: dict[str, dict[str, Any]] = {}
|
||||
|
||||
VOICE_CHANNELS = frozenset({"voice", "browser", "telegram"})
|
||||
|
||||
LOCATION_ALIASES: dict[str, dict[str, str]] = {
|
||||
"dubai": {"country": "AE", "q": "Dubai", "label": "Dubai (VAE)"},
|
||||
"abu dhabi": {"country": "AE", "q": "Abu Dhabi", "label": "Abu Dhabi (VAE)"},
|
||||
"sharjah": {"country": "AE", "q": "Sharjah", "label": "Sharjah (VAE)"},
|
||||
"vae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
|
||||
"uae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
|
||||
"emiraten": {"country": "AE", "q": "", "label": "VAE"},
|
||||
"saudi": {"country": "SA", "q": "", "label": "Saoedi-Arabië"},
|
||||
"riyadh": {"country": "SA", "q": "Riyadh", "label": "Riyadh (SA)"},
|
||||
"jeddah": {"country": "SA", "q": "Jeddah", "label": "Jeddah (SA)"},
|
||||
"qatar": {"country": "QA", "q": "", "label": "Qatar"},
|
||||
"doha": {"country": "QA", "q": "Doha", "label": "Doha (Qatar)"},
|
||||
"kuwait": {"country": "KW", "q": "", "label": "Koeweit"},
|
||||
"nederland": {"country": "NL", "q": "", "label": "Nederland"},
|
||||
"amsterdam": {"country": "NL", "q": "Amsterdam", "label": "Amsterdam"},
|
||||
"rotterdam": {"country": "NL", "q": "Rotterdam", "label": "Rotterdam"},
|
||||
"belgië": {"country": "BE", "q": "", "label": "België"},
|
||||
"belgie": {"country": "BE", "q": "", "label": "België"},
|
||||
"antwerpen": {"country": "BE", "q": "Antwerp", "label": "Antwerpen"},
|
||||
"duitsland": {"country": "DE", "q": "", "label": "Duitsland"},
|
||||
"berlijn": {"country": "DE", "q": "Berlin", "label": "Berlijn"},
|
||||
"frankfurt": {"country": "DE", "q": "Frankfurt", "label": "Frankfurt"},
|
||||
"turkije": {"country": "TR", "q": "", "label": "Turkije"},
|
||||
"istanbul": {"country": "TR", "q": "Istanbul", "label": "Istanbul"},
|
||||
"marokko": {"country": "MA", "q": "", "label": "Marokko"},
|
||||
"casablanca": {"country": "MA", "q": "Casablanca", "label": "Casablanca"},
|
||||
}
|
||||
|
||||
ENTITY_ALIASES: dict[str, str] = {
|
||||
"distri": "distributor,wholesaler,importer,logistics",
|
||||
"distributeur": "distributor,wholesaler,importer,logistics",
|
||||
"distributeurs": "distributor,wholesaler,importer,logistics",
|
||||
"groothandel": "wholesaler,distributor",
|
||||
"groothandels": "wholesaler,distributor",
|
||||
"importeur": "importer",
|
||||
"importeurs": "importer",
|
||||
"logistiek": "logistics",
|
||||
"restaurant": "restaurant",
|
||||
"restaurants": "restaurant",
|
||||
"cateraar": "caterer",
|
||||
"cateraars": "caterer",
|
||||
"slager": "butcher",
|
||||
"slagers": "butcher",
|
||||
"döner": "doner",
|
||||
"doner": "doner",
|
||||
}
|
||||
|
||||
SEARCH_TRIGGERS = (
|
||||
"zoek", "opzoeken", "vind", "zoeken", "toon", "laat zien", "lijst",
|
||||
"geef me", "haal op", "export intel", "wereldexport",
|
||||
)
|
||||
|
||||
YES_RE = re.compile(
|
||||
r"^(ja|jawel|jep|yep|ok|oke|oké|okay|klopt|bevestig|doe maar|graag|precies|goed|akkoord|start|uitvoeren|doorgaan)\b",
|
||||
re.I,
|
||||
)
|
||||
NO_RE = re.compile(r"^(nee|neen|stop|annuleer|niet|cancel|laat maar|wacht)\b", re.I)
|
||||
|
||||
|
||||
def _cleanup_sessions() -> None:
|
||||
now = time.time()
|
||||
dead = [k for k, v in _pending.items() if now - float(v.get("_ts", 0)) > SESSION_TTL_SEC]
|
||||
for k in dead:
|
||||
_pending.pop(k, None)
|
||||
|
||||
|
||||
def is_confirmation_yes(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
return bool(t and YES_RE.search(t))
|
||||
|
||||
|
||||
def is_confirmation_no(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
return bool(t and NO_RE.search(t))
|
||||
|
||||
|
||||
def wants_export_search(text: str) -> bool:
|
||||
t = (text or "").lower()
|
||||
if not any(k in t for k in SEARCH_TRIGGERS):
|
||||
return False
|
||||
has_loc = any(alias in t for alias in LOCATION_ALIASES)
|
||||
has_ent = any(alias in t for alias in ENTITY_ALIASES)
|
||||
return has_loc or has_ent
|
||||
|
||||
|
||||
def _detect_location(text: str) -> dict[str, str] | None:
|
||||
t = text.lower()
|
||||
best: tuple[int, dict[str, str]] | None = None
|
||||
for alias, loc in LOCATION_ALIASES.items():
|
||||
if alias in t:
|
||||
score = len(alias)
|
||||
if best is None or score > best[0]:
|
||||
best = (score, loc)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _detect_entity_types(text: str) -> str:
|
||||
t = text.lower()
|
||||
found: list[str] = []
|
||||
for alias, types in ENTITY_ALIASES.items():
|
||||
if alias in t:
|
||||
for et in types.split(","):
|
||||
if et not in found:
|
||||
found.append(et)
|
||||
if not found and any(w in t for w in ("distri", "distributeur", "groothandel", "b2b", "leverancier")):
|
||||
return "distributor,wholesaler,importer,logistics"
|
||||
return ",".join(found) if found else "distributor,wholesaler,importer,logistics"
|
||||
|
||||
|
||||
def _entity_label(entity_types: str) -> str:
|
||||
labels = {
|
||||
"distributor": "distributeurs",
|
||||
"wholesaler": "groothandels",
|
||||
"importer": "importeurs",
|
||||
"logistics": "logistiek",
|
||||
"restaurant": "restaurants",
|
||||
"caterer": "cateraars",
|
||||
"butcher": "slagers",
|
||||
"doner": "dönerzaken",
|
||||
}
|
||||
parts = [labels.get(x.strip(), x.strip()) for x in entity_types.split(",") if x.strip()]
|
||||
return ", ".join(parts) if parts else "bedrijven"
|
||||
|
||||
|
||||
def parse_export_search(text: str) -> dict[str, Any]:
|
||||
loc = _detect_location(text)
|
||||
entity_types = _detect_entity_types(text)
|
||||
missing: list[str] = []
|
||||
if not loc:
|
||||
missing.append("locatie")
|
||||
params: dict[str, Any] = {
|
||||
"country": (loc or {}).get("country", ""),
|
||||
"q": (loc or {}).get("q", ""),
|
||||
"entity_types": entity_types,
|
||||
"limit": 50,
|
||||
}
|
||||
label_loc = (loc or {}).get("label", "")
|
||||
return {
|
||||
"params": params,
|
||||
"location_label": label_loc,
|
||||
"entity_label": _entity_label(entity_types),
|
||||
"missing": missing,
|
||||
"incomplete": bool(missing),
|
||||
}
|
||||
|
||||
|
||||
def store_pending(session_id: str, action: dict[str, Any]) -> str:
|
||||
_cleanup_sessions()
|
||||
action_id = str(uuid.uuid4())[:12]
|
||||
action = dict(action)
|
||||
action["id"] = action_id
|
||||
action["_ts"] = time.time()
|
||||
_pending[session_id] = action
|
||||
return action_id
|
||||
|
||||
|
||||
def get_pending(session_id: str) -> dict[str, Any] | None:
|
||||
_cleanup_sessions()
|
||||
row = _pending.get(session_id)
|
||||
if not row:
|
||||
return None
|
||||
if time.time() - float(row.get("_ts", 0)) > SESSION_TTL_SEC:
|
||||
_pending.pop(session_id, None)
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def clear_pending(session_id: str) -> None:
|
||||
_pending.pop(session_id, None)
|
||||
|
||||
|
||||
def build_confirmation_question(parsed: dict[str, Any]) -> str:
|
||||
loc = parsed.get("location_label") or "de geselecteerde regio"
|
||||
ent = parsed.get("entity_label") or "bedrijven"
|
||||
return (
|
||||
f"Ik ga **{ent}** in **{loc}** voor je opzoeken in Export Intel.\n\n"
|
||||
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren, "
|
||||
"of geef aan wat ik moet aanpassen (bijv. alleen distributeurs, of een andere stad)."
|
||||
)
|
||||
|
||||
|
||||
def build_clarification_question(parsed: dict[str, Any]) -> str:
|
||||
missing = parsed.get("missing") or []
|
||||
if "locatie" in missing:
|
||||
return (
|
||||
"In welke **stad of welk land** wil je zoeken? "
|
||||
"Bijvoorbeeld: Dubai, VAE, Nederland, Frankfurt…"
|
||||
)
|
||||
return "Kun je iets specifieker zijn over wat je zoekt en waar?"
|
||||
|
||||
|
||||
async def fetch_export_entities(params: dict[str, Any]) -> dict[str, Any]:
|
||||
query = {k: v for k, v in params.items() if v not in (None, "")}
|
||||
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/entities"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(url, params=query)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _open_url(params: dict[str, Any]) -> str:
|
||||
qs = []
|
||||
if params.get("country"):
|
||||
qs.append(f"country={params['country']}")
|
||||
if params.get("q"):
|
||||
qs.append(f"q={params['q']}")
|
||||
qs.append("tab=distributors")
|
||||
return "/export-intel?" + "&".join(qs)
|
||||
|
||||
|
||||
async def execute_search_action(action: dict[str, Any]) -> dict[str, Any]:
|
||||
params = action.get("params") or {}
|
||||
data = await fetch_export_entities(params)
|
||||
items = data.get("items") or []
|
||||
total = int(data.get("total") or len(items))
|
||||
loc = action.get("location_label") or params.get("q") or params.get("country") or "markt"
|
||||
ent = action.get("entity_label") or "bedrijven"
|
||||
title = f"{ent.title()} — {loc}"
|
||||
if not items:
|
||||
reply = (
|
||||
f"Ik heb gezocht maar vond **geen** {ent} in {loc}. "
|
||||
"Wil je dat ik een sync start of een bredere regio probeer?"
|
||||
)
|
||||
else:
|
||||
reply = (
|
||||
f"Gevonden: **{total}** {ent} in {loc}. "
|
||||
f"Ik toon de eerste {min(len(items), 50)} in het resultatenvenster."
|
||||
)
|
||||
return {
|
||||
"agent": "sourcing",
|
||||
"agent_label": "Export Intel → Herman",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["sourcing", "export_intel"],
|
||||
"routing_reason": f"Export Intel zoekopdracht: {title}",
|
||||
"agent_steps": [
|
||||
{"agent": "export_intel", "status": "done", "message": f"{total} resultaten"},
|
||||
{"agent": "sourcing", "status": "delegated", "message": "Marktdata opgehaald"},
|
||||
],
|
||||
"needs_confirmation": False,
|
||||
"ui_actions": [
|
||||
{
|
||||
"type": "show_export_results",
|
||||
"title": title,
|
||||
"entities": items[:50],
|
||||
"total": total,
|
||||
"params": params,
|
||||
"open_url": _open_url(params),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _pending_summary(action: dict[str, Any]) -> dict[str, Any]:
|
||||
atype = action.get("type", "")
|
||||
base = {"id": action.get("id"), "type": atype}
|
||||
if atype == "export_intel_search":
|
||||
base["location_label"] = action.get("location_label")
|
||||
base["entity_label"] = action.get("entity_label")
|
||||
base["params"] = action.get("params")
|
||||
elif atype == "webbuilder_build":
|
||||
base["project"] = action.get("project")
|
||||
base["entity_label"] = f"website {action.get('project', '')}"
|
||||
base["location_label"] = "Agy · Antigravity"
|
||||
return base
|
||||
|
||||
|
||||
def build_webbuilder_confirmation(project: str, raw: str) -> str:
|
||||
snippet = (raw or "")[:240]
|
||||
return (
|
||||
f"Ik stuur **Agy (Antigravity)** op Hermes aan om een website te bouwen.\n\n"
|
||||
f"**Project:** {project}\n"
|
||||
f"**Opdracht:** {snippet}{'…' if len(raw or '') > 240 else ''}\n\n"
|
||||
"Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren."
|
||||
)
|
||||
|
||||
|
||||
async def execute_webbuilder_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
|
||||
raw = action.get("raw_message") or ""
|
||||
project = action.get("project") or webbuilder_agent.extract_project_name(raw)
|
||||
try:
|
||||
outcome = await webbuilder_agent.generate_from_message(raw, channel=channel, wait=False)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"agent": "webbuilder",
|
||||
"agent_label": "Web Builder",
|
||||
"reply": f"Agy kon niet starten op Hermes: {exc}\n\nControleer VM107 webbuilder-api (:8798).",
|
||||
"delegated_agents": ["webbuilder"],
|
||||
"needs_confirmation": False,
|
||||
}
|
||||
|
||||
project = outcome.get("project") or project
|
||||
preview = outcome.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
nas_path = outcome.get("nas_path") or ""
|
||||
reply = (
|
||||
f"Agy is gestart voor **{project}**.\n"
|
||||
f"Preview (na build): {preview}\n"
|
||||
f"NAS: {nas_path}\n"
|
||||
"Volg live: Agents → Terminals → Web Builder."
|
||||
)
|
||||
return {
|
||||
"agent": "webbuilder",
|
||||
"agent_label": "Agy / Web Builder → Herman",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["webbuilder"],
|
||||
"routing_reason": "Website via Antigravity CLI (agy) op Hermes VM107",
|
||||
"agent_steps": [
|
||||
{"agent": "webbuilder", "status": "running", "message": f"agy bouwt {project}"},
|
||||
{"agent": "herman", "status": "delegated", "message": "Voice → build gestart"},
|
||||
],
|
||||
"webbuilder_project": project,
|
||||
"webbuilder_preview_url": preview,
|
||||
"needs_confirmation": False,
|
||||
"ui_actions": [
|
||||
{
|
||||
"type": "open_webbuilder_build",
|
||||
"title": f"Website build — {project}",
|
||||
"project": project,
|
||||
"preview_url": preview,
|
||||
"nas_path": nas_path,
|
||||
"agents_url": "/agents",
|
||||
"status_hint": "Build duurt enkele minuten — preview opent na afloop",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def execute_pending_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
|
||||
atype = action.get("type")
|
||||
if atype == "webbuilder_build":
|
||||
return await execute_webbuilder_action(action, channel=channel)
|
||||
return await execute_search_action(action)
|
||||
|
||||
|
||||
def _confirmation_reminder(pending: dict[str, Any]) -> str:
|
||||
if pending.get("type") == "webbuilder_build":
|
||||
return build_webbuilder_confirmation(pending.get("project", "website"), pending.get("raw_message", ""))
|
||||
return build_confirmation_question(pending)
|
||||
|
||||
|
||||
async def handle_voice_command(
|
||||
message: str,
|
||||
session_id: str | None = None,
|
||||
confirm_action_id: str | None = None,
|
||||
channel: str = "voice",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Unified voice/browser router: bevestiging + export + webbuilder/agy."""
|
||||
sid = (session_id or "").strip() or "default"
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
pending = get_pending(sid)
|
||||
|
||||
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
|
||||
clear_pending(sid)
|
||||
return await execute_pending_action(pending, channel=channel)
|
||||
|
||||
if pending:
|
||||
if is_confirmation_yes(text):
|
||||
clear_pending(sid)
|
||||
return await execute_pending_action(pending, channel=channel)
|
||||
if is_confirmation_no(text):
|
||||
clear_pending(sid)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
|
||||
"needs_confirmation": False,
|
||||
}
|
||||
if pending.get("awaiting") == "location" and pending.get("type") == "export_intel_search":
|
||||
loc = _detect_location(text)
|
||||
if loc:
|
||||
merged = dict(pending)
|
||||
merged["params"] = dict(pending.get("params") or {})
|
||||
merged["params"]["country"] = loc["country"]
|
||||
merged["params"]["q"] = loc.get("q", "")
|
||||
merged["location_label"] = loc["label"]
|
||||
merged.pop("awaiting", None)
|
||||
merged.pop("incomplete", None)
|
||||
merged.pop("missing", None)
|
||||
action_id = store_pending(sid, merged)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_confirmation_question(merged),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": _pending_summary(merged),
|
||||
}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_clarification_question({"missing": ["locatie"]}),
|
||||
"needs_confirmation": True,
|
||||
"clarification": True,
|
||||
}
|
||||
if webbuilder_agent.wants_website(text):
|
||||
project = webbuilder_agent.extract_project_name(text)
|
||||
action_id = store_pending(sid, {
|
||||
"type": "webbuilder_build",
|
||||
"project": project,
|
||||
"raw_message": text,
|
||||
})
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_webbuilder_confirmation(project, text),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": _pending_summary(get_pending(sid) or {}),
|
||||
}
|
||||
if wants_export_search(text):
|
||||
parsed = parse_export_search(text)
|
||||
if not parsed.get("incomplete"):
|
||||
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_confirmation_question(parsed),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": _pending_summary(get_pending(sid) or {}),
|
||||
}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": (
|
||||
f"Ik wacht nog op bevestiging.\n\n{_confirmation_reminder(pending)}\n\n"
|
||||
"Zeg **ja** of **nee**."
|
||||
),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": _pending_summary(pending),
|
||||
}
|
||||
|
||||
if webbuilder_agent.wants_website(text):
|
||||
project = webbuilder_agent.extract_project_name(text)
|
||||
store_pending(sid, {"type": "webbuilder_build", "project": project, "raw_message": text})
|
||||
pa = get_pending(sid) or {}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_webbuilder_confirmation(project, text),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": _pending_summary(pa),
|
||||
}
|
||||
|
||||
return await handle_export_intent(message, session_id=session_id, confirm_action_id=confirm_action_id)
|
||||
|
||||
|
||||
async def handle_export_intent(
|
||||
message: str,
|
||||
session_id: str | None = None,
|
||||
confirm_action_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return Herman-shaped dict when export flow applies, else None."""
|
||||
sid = (session_id or "").strip() or "default"
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
pending = get_pending(sid)
|
||||
|
||||
if confirm_action_id and pending and pending.get("id") == confirm_action_id:
|
||||
clear_pending(sid)
|
||||
return await execute_pending_action(pending)
|
||||
|
||||
if pending:
|
||||
if is_confirmation_yes(text):
|
||||
clear_pending(sid)
|
||||
return await execute_pending_action(pending)
|
||||
if is_confirmation_no(text):
|
||||
clear_pending(sid)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
|
||||
"needs_confirmation": False,
|
||||
}
|
||||
if pending.get("awaiting") == "location":
|
||||
loc = _detect_location(text)
|
||||
if loc:
|
||||
merged = dict(pending)
|
||||
merged["params"] = dict(pending.get("params") or {})
|
||||
merged["params"]["country"] = loc["country"]
|
||||
merged["params"]["q"] = loc.get("q", "")
|
||||
merged["location_label"] = loc["label"]
|
||||
merged.pop("awaiting", None)
|
||||
merged.pop("incomplete", None)
|
||||
merged.pop("missing", None)
|
||||
action_id = store_pending(sid, merged)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_confirmation_question(merged),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
"type": "export_intel_search",
|
||||
"params": merged.get("params"),
|
||||
"location_label": merged.get("location_label"),
|
||||
"entity_label": merged.get("entity_label"),
|
||||
},
|
||||
}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_clarification_question({"missing": ["locatie"]}),
|
||||
"needs_confirmation": True,
|
||||
"clarification": True,
|
||||
}
|
||||
if wants_export_search(text):
|
||||
parsed = parse_export_search(text)
|
||||
if parsed.get("incomplete"):
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_clarification_question(parsed),
|
||||
"needs_confirmation": True,
|
||||
"clarification": True,
|
||||
}
|
||||
action_id = store_pending(sid, parsed)
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_confirmation_question(parsed),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
"type": "export_intel_search",
|
||||
"params": parsed.get("params"),
|
||||
"location_label": parsed.get("location_label"),
|
||||
"entity_label": parsed.get("entity_label"),
|
||||
},
|
||||
}
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": (
|
||||
f"Ik wacht nog op bevestiging: {build_confirmation_question(pending)}\n\n"
|
||||
"Zeg **ja** of **nee**, of stel een nieuwe zoekopdracht."
|
||||
),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": {
|
||||
"id": pending.get("id"),
|
||||
"type": pending.get("type", "export_intel_search"),
|
||||
"params": pending.get("params"),
|
||||
"location_label": pending.get("location_label"),
|
||||
"entity_label": pending.get("entity_label"),
|
||||
},
|
||||
}
|
||||
|
||||
if not wants_export_search(text):
|
||||
return None
|
||||
|
||||
parsed = parse_export_search(text)
|
||||
if parsed.get("incomplete"):
|
||||
store_pending(sid, {**parsed, "type": "export_intel_search", "awaiting": "location"})
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_clarification_question(parsed),
|
||||
"needs_confirmation": True,
|
||||
"clarification": True,
|
||||
}
|
||||
|
||||
action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
|
||||
return {
|
||||
"agent": "herman",
|
||||
"agent_label": "Herman",
|
||||
"reply": build_confirmation_question(parsed),
|
||||
"needs_confirmation": True,
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
"type": "export_intel_search",
|
||||
"params": parsed.get("params"),
|
||||
"location_label": parsed.get("location_label"),
|
||||
"entity_label": parsed.get("entity_label"),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Web Builder agent — websites bouwen via Hermes/agy API op VM107."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.agent_events_log import log_agent_event
|
||||
|
||||
HERMES_BUILD_URL = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798").rstrip("/")
|
||||
HERMES_PREVIEW_BASE = os.getenv("HERMES_PREVIEW_BASE", "http://10.4.7.27:8080").rstrip("/")
|
||||
|
||||
WEBSITE_KEYWORDS = (
|
||||
"maak website",
|
||||
"maak een website",
|
||||
"bouw website",
|
||||
"bouw een website",
|
||||
"genereer website",
|
||||
"website voor",
|
||||
"website bouwen",
|
||||
"landingspagina",
|
||||
"landing page",
|
||||
"webshop",
|
||||
"webpagina",
|
||||
"nieuwe site",
|
||||
"nieuwe website",
|
||||
"/website",
|
||||
"web builder",
|
||||
"webbuilder",
|
||||
"agy",
|
||||
"antigravity",
|
||||
"anti gravity",
|
||||
"anti-gravity",
|
||||
"site maken",
|
||||
"maak een site",
|
||||
"bouw een site",
|
||||
"website laten maken",
|
||||
"laat agy",
|
||||
"via agy",
|
||||
)
|
||||
|
||||
PROJECT_RE = re.compile(
|
||||
r"(?:website|site|project)\s+(?:voor\s+|called\s+|genaamd\s+)?['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
|
||||
re.I,
|
||||
)
|
||||
FOR_RE = re.compile(
|
||||
r"\bvoor\s+['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def wants_website(raw: str) -> bool:
|
||||
t = (raw or "").strip().lower()
|
||||
return any(k in t for k in WEBSITE_KEYWORDS)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
s = (name or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9_-]+", "-", s)
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
return s[:64] or "website"
|
||||
|
||||
|
||||
def extract_project_name(raw: str) -> str:
|
||||
t = (raw or "").strip()
|
||||
m = PROJECT_RE.search(t)
|
||||
if m:
|
||||
return slugify(m.group(1))
|
||||
m = FOR_RE.search(t)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
name = re.split(r"\s+(?:met|with|incl|including)\b", name, maxsplit=1, flags=re.I)[0]
|
||||
return slugify(name)
|
||||
for token in t.split():
|
||||
clean = slugify(token)
|
||||
if len(clean) >= 3 and clean not in (
|
||||
"maak", "bouw", "website", "site", "voor", "een", "the", "and", "met",
|
||||
"simpele", "landing", "page", "pagina", "landingspagina",
|
||||
):
|
||||
return clean
|
||||
return slugify(t[:40]) or "website"
|
||||
|
||||
|
||||
def extract_build_prompt(raw: str, project: str) -> str:
|
||||
body = (raw or "").strip()
|
||||
lower = body.lower()
|
||||
for k in WEBSITE_KEYWORDS:
|
||||
if lower.startswith(k):
|
||||
rest = body[len(k) :].strip(" :,-")
|
||||
if rest:
|
||||
return rest
|
||||
return (
|
||||
f"Bouw een complete, moderne, responsive website voor project '{project}'. "
|
||||
f"Opdracht: {body}. "
|
||||
"Gebruik index.html, style.css, script.js en een assets/ map. "
|
||||
"Foodlinkk halal kant-en-klaar food branding waar passend."
|
||||
)
|
||||
|
||||
|
||||
async def _hermes_get(path: str, timeout: float = 30.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{HERMES_BUILD_URL}{path}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "detail": resp.text[:500]}
|
||||
if resp.status_code >= 400:
|
||||
data.setdefault("ok", False)
|
||||
return data
|
||||
|
||||
|
||||
async def _hermes_post(path: str, payload: dict[str, Any], timeout: float = 60.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{HERMES_BUILD_URL}{path}", json=payload)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "detail": resp.text[:500]}
|
||||
if resp.status_code >= 400:
|
||||
data.setdefault("ok", False)
|
||||
data.setdefault("detail", resp.text[:500])
|
||||
return data
|
||||
|
||||
|
||||
async def list_projects() -> dict[str, Any]:
|
||||
return await _hermes_get("/api/projects")
|
||||
|
||||
|
||||
async def get_build_status(project: str) -> dict[str, Any]:
|
||||
slug = slugify(project)
|
||||
return await _hermes_get(f"/api/build/{slug}/status")
|
||||
|
||||
|
||||
async def build_from_message(raw: str, *, channel: str = "herman") -> dict[str, Any]:
|
||||
project = extract_project_name(raw)
|
||||
prompt = extract_build_prompt(raw, project)
|
||||
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_start",
|
||||
f"Start build: {project}",
|
||||
prompt[:4000],
|
||||
channel=channel,
|
||||
metadata={"project": project, "prompt": prompt[:500]},
|
||||
status="running",
|
||||
)
|
||||
|
||||
data = await _hermes_post(
|
||||
"/api/build",
|
||||
{"project": project, "prompt": prompt},
|
||||
timeout=45.0,
|
||||
)
|
||||
if not data.get("ok", True) and data.get("detail"):
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_error",
|
||||
f"Build mislukt: {project}",
|
||||
str(data.get("detail", data))[:2000],
|
||||
channel=channel,
|
||||
metadata={"project": project},
|
||||
status="error",
|
||||
)
|
||||
raise RuntimeError(str(data.get("detail") or data))
|
||||
|
||||
job_id = data.get("job_id") or project
|
||||
preview = data.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
|
||||
nas_path = data.get("nas_path") or f"//10.4.7.11/share/Websites/{project}/"
|
||||
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_running",
|
||||
f"agy bezig: {project}",
|
||||
f"Job {job_id}\nPreview: {preview}\nNAS: {nas_path}",
|
||||
channel=channel,
|
||||
metadata={
|
||||
"project": project,
|
||||
"job_id": job_id,
|
||||
"preview_url": preview,
|
||||
"nas_path": nas_path,
|
||||
"correlation_id": job_id,
|
||||
},
|
||||
status="running",
|
||||
)
|
||||
|
||||
return {
|
||||
"project": project,
|
||||
"job_id": job_id,
|
||||
"preview_url": preview,
|
||||
"nas_path": nas_path,
|
||||
"prompt": prompt,
|
||||
"hermes_status": data.get("status", "running"),
|
||||
}
|
||||
|
||||
|
||||
async def poll_until_done(project: str, *, channel: str = "herman", timeout: float = 1800.0) -> dict[str, Any]:
|
||||
"""Poll Hermes build status until completed or failed."""
|
||||
import asyncio
|
||||
|
||||
slug = slugify(project)
|
||||
elapsed = 0.0
|
||||
interval = 5.0
|
||||
last_log = ""
|
||||
|
||||
while elapsed < timeout:
|
||||
st = await get_build_status(slug)
|
||||
status = str(st.get("status") or "unknown")
|
||||
msg = str(st.get("message") or status)
|
||||
if msg != last_log:
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_progress",
|
||||
msg[:255],
|
||||
(st.get("log_tail") or "")[:4000],
|
||||
channel=channel,
|
||||
metadata={"project": slug, "status": status},
|
||||
status="running" if status in ("running", "queued") else status,
|
||||
)
|
||||
last_log = msg
|
||||
|
||||
if status == "completed":
|
||||
files = st.get("files") or []
|
||||
preview = st.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_done",
|
||||
f"Website klaar: {slug}",
|
||||
f"Bestanden: {', '.join(files[:8])}\nPreview: {preview}",
|
||||
channel=channel,
|
||||
metadata={
|
||||
"project": slug,
|
||||
"preview_url": preview,
|
||||
"files": files,
|
||||
"for_herman": True,
|
||||
"source_agent": "webbuilder",
|
||||
},
|
||||
status="completed",
|
||||
)
|
||||
return {**st, "project": slug, "preview_url": preview}
|
||||
|
||||
if status == "failed":
|
||||
detail = str(st.get("error") or st.get("message") or "Build mislukt")
|
||||
log_agent_event(
|
||||
"webbuilder",
|
||||
"website_build_error",
|
||||
f"Build mislukt: {slug}",
|
||||
detail[:2000],
|
||||
channel=channel,
|
||||
metadata={"project": slug},
|
||||
status="error",
|
||||
)
|
||||
raise RuntimeError(detail)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
raise TimeoutError(f"Build timeout voor {slug} na {int(timeout)}s")
|
||||
|
||||
|
||||
async def generate_from_message(raw: str, *, channel: str = "herman", wait: bool = True) -> dict[str, Any]:
|
||||
started = await build_from_message(raw, channel=channel)
|
||||
if wait:
|
||||
finished = await poll_until_done(started["project"], channel=channel)
|
||||
started.update(finished)
|
||||
return started
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
|
||||
<title>{% block title %}{{ page_title }} · Cucina{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json?v=2" />
|
||||
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=4" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<script defer src="/static/js/alpine.min.js?v=1"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=1"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'persona-ceo': persona === 'ceo', 'persona-cto': persona === 'cto' }">
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
|
||||
<div class="app-sidebar-brand">
|
||||
<p class="brand-title">Cucina · Foodlinkk</p>
|
||||
<p class="brand-name" x-text="persona === 'ceo' ? 'CEO Dashboard' : 'CTO Dashboard'">CEO Dashboard</p>
|
||||
</div>
|
||||
|
||||
<div class="persona-switch" role="tablist" aria-label="Dashboard modus">
|
||||
<button type="button" role="tab" class="persona-btn persona-btn-ceo"
|
||||
:class="{ active: persona === 'ceo' }" @click="setPersona('ceo')"
|
||||
:aria-selected="persona === 'ceo'" data-i18n="nav.persona_ceo">CEO · Business</button>
|
||||
<button type="button" role="tab" class="persona-btn persona-btn-cto"
|
||||
:class="{ active: persona === 'cto' }" @click="setPersona('cto')"
|
||||
:aria-selected="persona === 'cto'" data-i18n="nav.persona_cto">CTO · Tech</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CEO · BUSINESS ═══ -->
|
||||
<template x-if="persona === 'ceo'">
|
||||
<div class="persona-nav">
|
||||
<div class="app-sidebar-group">
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.ceo_home">CEO Overzicht</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
|
||||
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.business">Business</div>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
|
||||
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ CTO · TECH ═══ -->
|
||||
<template x-if="persona === 'cto'">
|
||||
<div class="persona-nav">
|
||||
<div class="app-sidebar-group">
|
||||
<a href="/cto" class="nav-pill nav-pill-ops {% if request.url.path == '/cto' %}active{% endif %}" data-i18n="nav.cto_home">CTO Overzicht</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.devops">DevOps</div>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
|
||||
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.tools_api">Tools API</a>
|
||||
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="nav-pill nav-pill-analytics" data-i18n="nav.grafana">Grafana</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<div class="mobile-nav-overlay" x-show="navOpen" @click="navOpen=false" x-cloak></div>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-topbar">
|
||||
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen">☰</button>
|
||||
<span class="page-tip">
|
||||
<span class="persona-badge" :class="persona === 'ceo' ? 'persona-badge-ceo' : 'persona-badge-cto'" x-text="persona === 'ceo' ? 'CEO' : 'CTO'"></span>
|
||||
Cucina · Foodlinkk
|
||||
</span>
|
||||
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
|
||||
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
|
||||
<option value="nl">NL</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
||||
</header>
|
||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
|
||||
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/i18n.js?v=4"></script>
|
||||
<script src="/static/js/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/global-viz.js?v=1"></script>
|
||||
<script src="/static/js/live-pulse.js?v=2"></script>
|
||||
<script>
|
||||
window.FoodlinkkPersona = (function () {
|
||||
var CTO_PREFIXES = ['/cto', '/ops', '/agents', '/browser', '/monitor', '/studio', '/voice', '/settings'];
|
||||
function isCtoPath(path) {
|
||||
return CTO_PREFIXES.some(function (p) { return path === p || path.indexOf(p + '/') === 0; });
|
||||
}
|
||||
function detect(path) {
|
||||
var stored = localStorage.getItem('foodlinkk-persona');
|
||||
if (stored === 'ceo' || stored === 'cto') return stored;
|
||||
return isCtoPath(path) ? 'cto' : 'ceo';
|
||||
}
|
||||
return { isCtoPath: isCtoPath, detect: detect };
|
||||
})();
|
||||
|
||||
function appShell() {
|
||||
return {
|
||||
locale: 'nl',
|
||||
persona: 'ceo',
|
||||
drawerOpen: false,
|
||||
navOpen: false,
|
||||
showInstall: false,
|
||||
_deferredPrompt: null,
|
||||
clockDate: '—',
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
async initApp() {
|
||||
this.persona = window.FoodlinkkPersona.detect(location.pathname);
|
||||
if (window.I18n) await window.I18n.init();
|
||||
this.locale = window.I18n ? window.I18n.locale() : 'nl';
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
},
|
||||
setPersona(p) {
|
||||
if (p === this.persona) return;
|
||||
this.persona = p;
|
||||
localStorage.setItem('foodlinkk-persona', p);
|
||||
var path = location.pathname;
|
||||
var onCto = window.FoodlinkkPersona.isCtoPath(path);
|
||||
if (p === 'ceo' && onCto) { location.href = '/'; return; }
|
||||
if (p === 'cto' && !onCto) { location.href = '/cto'; return; }
|
||||
},
|
||||
async setLocale(loc) {
|
||||
if (window.I18n) await window.I18n.setLocale(loc);
|
||||
this.locale = loc;
|
||||
},
|
||||
async initClock() {
|
||||
try {
|
||||
const r = await fetch('/api/server-time').then(x => x.json());
|
||||
this._offset = new Date(r.utc).getTime() - Date.now();
|
||||
} catch (e) { this._offset = 0; }
|
||||
const tick = () => {
|
||||
const d = new Date(Date.now() + this._offset);
|
||||
this.clockDate = d.toLocaleDateString('nl-NL', { timeZone: 'Europe/Amsterdam', weekday: 'short', day: 'numeric', month: 'short' });
|
||||
this.clockTime = d.toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
},
|
||||
initPwa() {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return;
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.showInstall = !localStorage.getItem('pwa-dismiss');
|
||||
}
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this._deferredPrompt = e;
|
||||
this.showInstall = true;
|
||||
});
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
|
||||
}
|
||||
},
|
||||
async installPwa() {
|
||||
if (this._deferredPrompt) {
|
||||
this._deferredPrompt.prompt();
|
||||
await this._deferredPrompt.userChoice;
|
||||
this._deferredPrompt = null;
|
||||
this.showInstall = false;
|
||||
return;
|
||||
}
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
|
||||
} else {
|
||||
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
|
||||
}
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(function () {
|
||||
if (window.Alpine) return;
|
||||
document.querySelectorAll('[x-cloak]').forEach(function (el) { el.removeAttribute('x-cloak'); });
|
||||
document.body.classList.add('alpine-missing');
|
||||
console.error('Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).');
|
||||
}, 2500);
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo" x-data="ctoDashboard()" x-init="init()">
|
||||
|
||||
<div class="herman-hero">
|
||||
<h1>CTO Dashboard <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">Platform · agents · infra · DevOps · goedkeuringen</p>
|
||||
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
|
||||
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
|
||||
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
|
||||
<a href="/agents?tab=approvals" class="beurs-mini-chip">✓ Goedkeuringen</a>
|
||||
<a href="/browser" class="beurs-mini-chip">🌐 Browser agent</a>
|
||||
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
|
||||
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
|
||||
<a href="http://10.4.7.18:3002" target="_blank" rel="noopener" class="beurs-mini-chip">📈 Grafana</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel herman-briefing">
|
||||
<header class="herman-briefing-header">
|
||||
<div>
|
||||
<h2>Platform status</h2>
|
||||
<div class="briefing-meta" x-text="opsMeta">Laden…</div>
|
||||
</div>
|
||||
<div class="herman-briefing-actions">
|
||||
<button type="button" class="btn btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
|
||||
<a href="/ops" class="btn btn-pulse-green">Volledige IT Ops →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="hm-neo-kpi-row" style="margin-bottom:1.25rem">
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Health</div>
|
||||
<div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Online nodes</div>
|
||||
<div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? '—'"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Services</div>
|
||||
<div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Open goedkeuringen</div>
|
||||
<div class="hm-neo-kpi-value" x-text="pendingApprovals"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Agent events (24u)</div>
|
||||
<div class="hm-neo-kpi-value" x-text="agentEvents24h"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<div class="hm-neo-kpi-label">Browser sessies (24u)</div>
|
||||
<div class="hm-neo-kpi-value">{{ kpis.browser_sessions_24h }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hm-dash-two-col">
|
||||
<section class="hm-dash-section" data-widget="approvals">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen</h3>
|
||||
<div class="panel" style="background:transparent;border:none;padding:0">
|
||||
<p class="page-tip">SysOps vraagt toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>.</p>
|
||||
<div id="cto-approvals-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Maintenance</h3>
|
||||
<template x-if="!maintenance.length">
|
||||
<p class="empty-state">Geen open maintenance items 🎉</p>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="n in maintenance.slice(0, 6)" :key="n.id">
|
||||
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
|
||||
<strong x-text="n.title"></strong>
|
||||
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="page-tip" style="margin-top:0.75rem"><a href="/ops">Alle maintenance →</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="hub-grid hm-dash-feed" style="margin-top:1.25rem">
|
||||
<section class="panel">
|
||||
<h2>Agent feed <span class="live-badge" id="cto-feed-badge">Live</span></h2>
|
||||
<div id="cto-agent-feed">
|
||||
{% for ev in agent_feed[:12] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
|
||||
{{ ev.title or ev.event_type }}
|
||||
</div>
|
||||
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Browser sessies</h2>
|
||||
{% if browser_sessions %}
|
||||
{% for s in browser_sessions[:6] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ s.created_at[11:16] if s.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-browser">browser</span>
|
||||
{{ s.title or s.url or s.task }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-state">Geen recente browser-sessies.</p>
|
||||
{% endif %}
|
||||
<p class="page-tip" style="margin-top:0.75rem"><a href="/browser">Browser agent →</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function ctoDashboard() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: {},
|
||||
meta: {},
|
||||
maintenance: [],
|
||||
opsMeta: 'Laden…',
|
||||
pendingApprovals: {{ kpis.pending_approvals }},
|
||||
agentEvents24h: 0,
|
||||
_pollStop: null,
|
||||
_wsStop: null,
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'cto');
|
||||
await this.refresh(false);
|
||||
await this.loadApprovals();
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refresh(false); this.loadApprovals(); }, 30000);
|
||||
this._wsStop = CockpitLive.connectFeed((data) => {
|
||||
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('cto-agent-feed'), data.events);
|
||||
CockpitLive.updateLiveBadge(document.getElementById('cto-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
||||
});
|
||||
},
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [status, maint, analytics] = await Promise.all([
|
||||
fetch('/api/ops/status').then(x => x.json()).catch(() => ({})),
|
||||
fetch('/api/ops/maintenance?limit=12').then(x => x.json()).catch(() => ({ items: [] })),
|
||||
fetch('/analytics/api/data').then(x => x.json()).catch(() => ({})),
|
||||
]);
|
||||
this.summary = status || { counts: {} };
|
||||
this.meta = (status.topology && status.topology.meta) || status.sources || {};
|
||||
this.maintenance = (maint.items || []).filter(n => !n.resolved);
|
||||
this.opsMeta = 'Sync: ' + new Date().toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam' });
|
||||
if (analytics.kpis) {
|
||||
this.pendingApprovals = analytics.kpis.pending_approvals ?? this.pendingApprovals;
|
||||
this.agentEvents24h = analytics.kpis.agent_events ?? 0;
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Platform data bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (toast !== false) Cockpit.toast(e.message || 'Fout', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
async loadApprovals() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
|
||||
const el = document.getElementById('cto-approvals-list');
|
||||
if (!el) return;
|
||||
const items = r.items || [];
|
||||
this.pendingApprovals = items.length;
|
||||
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
|
||||
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
|
||||
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
|
||||
items.map(i => '<tr><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
|
||||
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓</button>' +
|
||||
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
|
||||
}));
|
||||
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
|
||||
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 & M&A</option>
|
||||
<option value="regelgeving">Regelgeving</option>
|
||||
<option value="cbs">CBS</option>
|
||||
</select>
|
||||
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
|
||||
</div>
|
||||
|
||||
<div class="herman-rss-grid">
|
||||
<section class="herman-rss-col herman-rss-col-main">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
|
||||
<div class="herman-rss-list">
|
||||
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
|
||||
<article class="feed-card herman-rss-card">
|
||||
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
|
||||
<div class="feed-actions">
|
||||
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
|
||||
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
|
||||
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="herman-rss-col herman-rss-col-side">
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
|
||||
<h4>Regelgeving</h4>
|
||||
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
|
||||
<h4>CBS & data</h4>
|
||||
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="rssBookmarks.length">
|
||||
<h4>★ Opgeslagen</h4>
|
||||
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
|
||||
<small x-text="b.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=4"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=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 %}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
|
||||
(function () {
|
||||
var DEFAULT = 'overview';
|
||||
|
||||
function radioId(tab) {
|
||||
return 'doctab-' + tab;
|
||||
}
|
||||
|
||||
function getActiveTab() {
|
||||
var checked = document.querySelector('.doc-tab-input:checked');
|
||||
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
|
||||
}
|
||||
|
||||
function show(tab) {
|
||||
if (!tab) tab = DEFAULT;
|
||||
var radio = document.getElementById(radioId(tab));
|
||||
if (radio) radio.checked = true;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
window.__docActiveTab = tab;
|
||||
}
|
||||
|
||||
function syncAlpine(tab) {
|
||||
var hub = document.querySelector('.doc-hub');
|
||||
if (!hub || !window.Alpine) return;
|
||||
try {
|
||||
var d = Alpine.$data(hub);
|
||||
if (!d) return;
|
||||
if (d.activeTab !== tab) d.activeTab = tab;
|
||||
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
|
||||
} catch (e) {
|
||||
console.warn('doc-tabs alpine', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
var tab = getActiveTab();
|
||||
window.__docActiveTab = tab;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
syncAlpine(tab);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
|
||||
if (radio.dataset.bound) return;
|
||||
radio.dataset.bound = '1';
|
||||
radio.addEventListener('change', onTabChange);
|
||||
});
|
||||
}
|
||||
|
||||
window.DocTabs = { show: show, getActive: getActiveTab };
|
||||
|
||||
bind();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
});
|
||||
document.addEventListener('alpine:initialized', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
syncAlpine(getActiveTab());
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,893 @@
|
||||
/* Documents & NAS Workbench v2 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.doc-hub {
|
||||
--doc-accent: #00e5ff;
|
||||
--doc-gold: #ffd700;
|
||||
--doc-purple: #a855f7;
|
||||
--doc-border: rgba(0, 229, 255, 0.14);
|
||||
--doc-text: #f1f5f9;
|
||||
--doc-muted: #94a3b8;
|
||||
--doc-dim: #64748b;
|
||||
}
|
||||
|
||||
.doc-hub .page-header h1 {
|
||||
font-size: 1.65rem;
|
||||
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.85);
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.doc-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
transition: all 0.18s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
|
||||
|
||||
.doc-nav-btn.active,
|
||||
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
|
||||
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
|
||||
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"],
|
||||
#doctab-analytics360:checked ~ #doc-nav label[for="doctab-analytics360"] {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Workbench shell */
|
||||
.doc-workbench {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--doc-border);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
|
||||
.doc-tab-input {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#doc-tab-stage {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
#doc-tab-stage [data-tab-panel] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
|
||||
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
|
||||
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
|
||||
#doctab-analytics360:checked ~ #doc-tab-stage [data-tab-panel="analytics360"],
|
||||
#doc-tab-stage [data-tab-panel].tab-active {
|
||||
display: block !important;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
|
||||
font-size: 0.65rem;
|
||||
color: var(--doc-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-version-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.doc-row-fallback { opacity: 0.85; }
|
||||
|
||||
.share-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.share-crumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
|
||||
.share-crumb-sep { color: #475569; }
|
||||
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
|
||||
.share-browser-list { max-height: 52vh; overflow-y: auto; }
|
||||
|
||||
.doc-wb-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
.doc-wb-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
|
||||
|
||||
.doc-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.doc-kpi {
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.doc-kpi:last-child { border-right: none; }
|
||||
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
|
||||
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
|
||||
|
||||
.doc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
|
||||
|
||||
/* Split layout */
|
||||
.doc-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
|
||||
|
||||
.doc-split-col {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-split-col:last-child { border-right: none; }
|
||||
|
||||
.doc-chart-wrap { height: 140px; position: relative; }
|
||||
|
||||
/* Document table */
|
||||
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
|
||||
|
||||
.doc-table-wrap .data-table { font-size: 0.78rem; }
|
||||
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
|
||||
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
|
||||
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
|
||||
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
|
||||
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
|
||||
|
||||
/* Label chips */
|
||||
.doc-chip {
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.55rem;
|
||||
margin: 0.12rem 0.12rem 0 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
|
||||
|
||||
/* Photo grid */
|
||||
.photo-masonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.photo-tile {
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(10, 15, 26, 0.95);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.photo-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.photo-tile img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
background: #060a10;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-tile-body {
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.photo-tile-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
|
||||
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.photo-tile-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
min-height: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.photo-tile-ocr {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
color: var(--doc-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.8em;
|
||||
}
|
||||
|
||||
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
|
||||
|
||||
/* Label filter chips row */
|
||||
.label-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.label-filter-chip {
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.label-filter-chip.active, .label-filter-chip:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
color: #e0f2fe;
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── Label modal (fixed overlap + dark text) ── */
|
||||
.label-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.label-modal-backdrop.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label-modal {
|
||||
width: min(780px, 100%);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 1.35rem;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
|
||||
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
|
||||
|
||||
.label-modal-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
|
||||
|
||||
.label-modal-preview img {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
max-height: 160px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.modal-ocr-box {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1 !important;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-ocr-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.label-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-height: 92px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.label-pick:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick.selected {
|
||||
border-color: var(--pick-color, #00e5ff);
|
||||
background: rgba(0, 229, 255, 0.14);
|
||||
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
|
||||
|
||||
.label-pick-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.label-pick.selected .label-pick-name { color: #f8fafc; }
|
||||
|
||||
.label-modal-extra {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-modal-extra label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--doc-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.label-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
/* Document label drawer */
|
||||
.doc-label-drawer {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
|
||||
|
||||
/* ── Docling workbench ── */
|
||||
.dl-workbench { min-height: 620px; }
|
||||
|
||||
.dl-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 300px 1fr;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
/* v3 split workbench: files | editor+preview */
|
||||
.dl-body-v3 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) 1fr;
|
||||
min-height: 580px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
|
||||
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
|
||||
|
||||
.dl-pipeline-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dl-pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 580px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dl-workspace.empty { justify-content: center; align-items: center; }
|
||||
|
||||
.dl-workspace-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-ws-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
|
||||
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
|
||||
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
|
||||
|
||||
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
|
||||
|
||||
.dl-workspace-panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
}
|
||||
|
||||
.dl-pane:last-child { border-right: none; }
|
||||
|
||||
.dl-pane-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-export-tabs-inline { margin: 0; }
|
||||
|
||||
.dl-editor-full {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.dl-preview-viewport {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-viewport .dl-preview-box {
|
||||
min-height: 200px;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dl-preview-iframe {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
|
||||
.dl-file-meta { flex: 1; min-width: 0; }
|
||||
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
|
||||
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
|
||||
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
|
||||
|
||||
.dl-body-v3 .dl-sidebar {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body-v3 { grid-template-columns: 1fr; }
|
||||
.dl-body-v3 .dl-sidebar { max-height: 280px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body { grid-template-columns: 240px 1fr; }
|
||||
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
|
||||
}
|
||||
|
||||
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
overflow-y: auto;
|
||||
max-height: 680px;
|
||||
}
|
||||
|
||||
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
|
||||
.dl-options { background: rgba(0, 0, 0, 0.12); }
|
||||
|
||||
.dl-section-title {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--doc-dim);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-ext-chip {
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.dl-file-item {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.dl-file-item.selected {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dl-file-badge {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
|
||||
|
||||
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
font-size: 0.76rem;
|
||||
color: var(--doc-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.dl-toggle:has(input:checked) {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
color: #e0f2fe;
|
||||
}
|
||||
|
||||
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
|
||||
|
||||
.dl-preview-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
|
||||
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-tab {
|
||||
padding: 0.32rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-preview-box {
|
||||
min-height: 320px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dl-preview-box.rendered { white-space: normal; }
|
||||
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
|
||||
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
|
||||
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
|
||||
|
||||
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
|
||||
|
||||
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-stat {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
|
||||
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.dl-history-item {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
|
||||
|
||||
.dl-progress {
|
||||
height: 3px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dl-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00e5ff, #a855f7);
|
||||
animation: dl-pulse 1.2s ease-in-out infinite;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.dl-editor {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ppt-workbench { min-height: 560px; }
|
||||
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
|
||||
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
|
||||
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
|
||||
.ppt-editor { padding: 1rem; }
|
||||
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
|
||||
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
|
||||
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
|
||||
|
||||
/* ── Document chat (RAG) ── */
|
||||
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
|
||||
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
|
||||
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
|
||||
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
|
||||
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
|
||||
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
|
||||
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
|
||||
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
|
||||
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
|
||||
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
|
||||
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
|
||||
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
|
||||
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
|
||||
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
|
||||
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
.doc-chat-input .form-input { flex: 1; }
|
||||
|
||||
/* ── 360 Analytics ── */
|
||||
.analytics360-workbench { min-height: 560px; }
|
||||
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
|
||||
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
|
||||
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
|
||||
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
|
||||
.a360-links { max-height: 200px; overflow-y: auto; }
|
||||
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); }
|
||||
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(0,229,255,0.1); color: #7dd3fc; }
|
||||
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
|
||||
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
|
||||
|
||||
@keyframes dl-pulse {
|
||||
0%, 100% { transform: translateX(-100%); width: 30%; }
|
||||
50% { transform: translateX(200%); width: 50%; }
|
||||
}
|
||||
|
||||
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Documents · NAS Intelligence · Foodlinkk{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/documents-v2.css?v=19" />
|
||||
<script>
|
||||
window.__DOC_DATA = {
|
||||
topWords: {{ top_words | tojson }},
|
||||
summary: {{ summary | tojson }},
|
||||
documents: {{ documents | tojson }},
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/documents-v2.js?v=19"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="doc-hub" x-data="documentsDash()" x-init="init()">
|
||||
|
||||
<input type="radio" name="doctab" id="doctab-overview" class="doc-tab-input" value="overview" checked aria-hidden="true" tabindex="-1" />
|
||||
<input type="radio" name="doctab" id="doctab-labels" class="doc-tab-input" value="labels" aria-hidden="true" tabindex="-1" />
|
||||
<input type="radio" name="doctab" id="doctab-docling" class="doc-tab-input" value="docling" aria-hidden="true" tabindex="-1" />
|
||||
<input type="radio" name="doctab" id="doctab-powerpoint" class="doc-tab-input" value="powerpoint" aria-hidden="true" tabindex="-1" />
|
||||
<input type="radio" name="doctab" id="doctab-chat" class="doc-tab-input" value="chat" aria-hidden="true" tabindex="-1" />
|
||||
<input type="radio" name="doctab" id="doctab-analytics360" class="doc-tab-input" value="analytics360" aria-hidden="true" tabindex="-1" />
|
||||
|
||||
<header class="page-header herman-hero" style="margin-bottom:1rem">
|
||||
<h1>NAS Document Intelligence</h1>
|
||||
<p class="hero-sub">Corpus · sentiment · labeling · Docling conversie</p>
|
||||
<p class="page-tip" id="doc-hub-status" style="margin-top:0.5rem;color:#fbbf24;display:none"></p>
|
||||
</header>
|
||||
|
||||
<nav class="doc-nav" id="doc-nav" aria-label="Document secties">
|
||||
<label for="doctab-overview" class="doc-nav-btn">Overzicht</label>
|
||||
<label for="doctab-labels" class="doc-nav-btn">Labels & foto's</label>
|
||||
<label for="doctab-docling" class="doc-nav-btn">Workbench</label>
|
||||
<label for="doctab-powerpoint" class="doc-nav-btn">PowerPoint</label>
|
||||
<label for="doctab-chat" class="doc-nav-btn">Chat</label>
|
||||
<label for="doctab-analytics360" class="doc-nav-btn">360° Klant</label>
|
||||
</nav>
|
||||
|
||||
<div id="doc-tab-stage" class="doc-tab-stage">
|
||||
|
||||
<!-- ═══ OVERVIEW ═══ -->
|
||||
<div data-tab-panel="overview" class="doc-workbench doc-panel">
|
||||
<div class="doc-kpi-strip">
|
||||
<div class="doc-kpi" @click="filterSentiment=''; applyFilters()">
|
||||
<div class="lbl">Documenten</div>
|
||||
<div class="val" x-text="filteredDocs.length"></div>
|
||||
</div>
|
||||
<div class="doc-kpi">
|
||||
<div class="lbl">Totaal woorden</div>
|
||||
<div class="val">{{ summary.total_words }}</div>
|
||||
</div>
|
||||
<div class="doc-kpi" @click="switchTab('labels')">
|
||||
<div class="lbl">Foto's OCR</div>
|
||||
<div class="val" x-text="photos.length"></div>
|
||||
</div>
|
||||
<div class="doc-kpi">
|
||||
<div class="lbl">Gem. sentiment</div>
|
||||
<div class="val">{{ summary.avg_sentiment }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-toolbar">
|
||||
<input type="search" class="form-input" placeholder="Zoek bestand of label…" x-model="filterQuery" @input="applyFilters()" />
|
||||
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
|
||||
<option value="">Alle sentiment</option>
|
||||
<option value="positive">Positief</option>
|
||||
<option value="neutral">Neutraal</option>
|
||||
<option value="negative">Negatief</option>
|
||||
</select>
|
||||
<select class="form-input" x-model="filterType" @change="applyFilters()">
|
||||
<option value="">Alle types</option>
|
||||
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
|
||||
</select>
|
||||
<select class="form-input" x-model="filterLabel" @change="applyFilters()">
|
||||
<option value="">Alle labels</option>
|
||||
<template x-for="lb in labelVocabulary" :key="lb.label">
|
||||
<option :value="lb.label" x-text="lb.label + ' (' + lb.usage_count + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<label style="font-size:0.78rem;color:#64748b;display:flex;align-items:center;gap:0.35rem">
|
||||
<input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live
|
||||
</label>
|
||||
<button class="btn btn-secondary btn-sm" @click="openSelectedInWorkbench()" :disabled="!selectedSharePaths.length">Open in Workbench (<span x-text="selectedSharePaths.length"></span>)</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="refreshShare()">Share scan</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">
|
||||
<span x-text="nasOcrBusy ? 'OCR…' : 'NAS OCR'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="doc-split">
|
||||
<div class="doc-split-col">
|
||||
<div class="doc-section-title" style="font-size:0.65rem;text-transform:uppercase;letter-spacing:0.1em;color:#64748b;margin-bottom:0.5rem">Analytics</div>
|
||||
<div class="doc-chart-wrap"><canvas id="wordsChart"></canvas></div>
|
||||
<div class="doc-chart-wrap" style="margin-top:0.75rem"><canvas id="sentimentChart"></canvas></div>
|
||||
</div>
|
||||
<div class="doc-split-col doc-table-wrap">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||
<strong style="color:#f1f5f9;font-size:0.85rem">NAS documenten</strong>
|
||||
<span style="font-size:0.72rem;color:#64748b" x-text="filteredDocs.length + ' resultaten'"></span>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th style="width:2rem"></th><th>Bestand</th><th>Gewijzigd</th><th>Labels</th><th>Sentiment</th><th>Woorden</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="doc-table-body">
|
||||
{% for doc in documents[:80] %}
|
||||
<tr class="clickable-row doc-row-fallback">
|
||||
<td></td>
|
||||
<td><strong>{{ doc.filename }}</strong><span class="path-cell">{{ doc.storage_path }}</span></td>
|
||||
<td class="doc-ts-cell">{{ doc.modified_at or doc.analyzed_at or '—' }}</td>
|
||||
<td>{% if doc.user_labels %}{% for lb in doc.user_labels %}<span class="doc-chip">{{ lb }}</span>{% endfor %}{% else %}<span class="doc-chip muted">—</span>{% endif %}</td>
|
||||
<td><span class="badge badge-{{ doc.sentiment_label or 'neutral' }}">{{ doc.sentiment_label or '—' }}</span></td>
|
||||
<td>{{ doc.word_count or 0 }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<template x-for="doc in filteredDocs" :key="doc.storage_path">
|
||||
<tr class="clickable-row" :class="activeDoc && activeDoc.storage_path === doc.storage_path && 'doc-row-selected'">
|
||||
<td @click.stop>
|
||||
<input type="checkbox" :checked="isShareSelected(doc.storage_path)" @change="toggleShareSelect(doc.storage_path)" />
|
||||
</td>
|
||||
<td @click="openDocLabel(doc)">
|
||||
<strong x-text="doc.filename"></strong>
|
||||
<span class="path-cell" x-text="doc.storage_path"></span>
|
||||
</td>
|
||||
<td class="doc-ts-cell" x-text="formatTs(doc.modified_at || doc.analyzed_at)"></td>
|
||||
<td @click="openDocLabel(doc)">
|
||||
<template x-for="lb in (doc.user_labels || [])" :key="lb">
|
||||
<span class="doc-chip" x-text="lb"></span>
|
||||
</template>
|
||||
<span class="doc-chip muted" x-show="!(doc.user_labels || []).length">—</span>
|
||||
</td>
|
||||
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
|
||||
<td x-text="doc.word_count"></td>
|
||||
<td @click.stop>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="openInWorkbench(doc.storage_path)">Bewerk</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-label-drawer" x-show="activeDoc">
|
||||
<h3>Label document — <span x-text="activeDoc && activeDoc.filename"></span></h3>
|
||||
<div class="label-modal-grid">
|
||||
<template x-for="t in documentTaxonomy" :key="t.id">
|
||||
<button type="button" class="label-pick" :class="docSelectedLabels.includes(t.id) && 'selected'"
|
||||
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleDocLabel(t.id)">
|
||||
<span class="label-pick-emoji" x-text="t.emoji"></span>
|
||||
<span class="label-pick-name" x-text="t.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="label-modal-extra">
|
||||
<div><label>Extra labels</label><input type="text" class="form-input" x-model="docLabelsInput" placeholder="komma-gescheiden" /></div>
|
||||
<div><label>Notities</label><textarea class="form-input" rows="2" x-model="docNotesInput"></textarea></div>
|
||||
</div>
|
||||
<div class="label-modal-actions" style="border:none;padding-top:0.75rem">
|
||||
<button class="btn btn-secondary btn-sm" @click="activeDoc=null">Sluiten</button>
|
||||
<button class="btn btn-primary btn-sm" @click="saveDocLabels()">Opslaan</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="doclingSelected=activeDoc.storage_path; switchTab('docling')">→ Docling</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LABELS & PHOTOS ═══ -->
|
||||
<div data-tab-panel="labels" class="doc-workbench doc-panel">
|
||||
<div class="doc-wb-head">
|
||||
<div>
|
||||
<h2>Foto labeling studio</h2>
|
||||
<p>Klik een foto → kies label-type → opslaan</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
|
||||
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:200px" />
|
||||
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">Vernieuwen</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="nasOcrBusy" @click="batchNasOcr(false)">NAS OCR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label-filter-row">
|
||||
<button type="button" class="label-filter-chip" :class="!photoFilterLabel && 'active'" @click="photoFilterLabel=''">Alle</button>
|
||||
<template x-for="t in photoTaxonomy" :key="'f'+t.id">
|
||||
<button type="button" class="label-filter-chip" :class="photoFilterLabel===t.id && 'active'"
|
||||
@click="photoFilterLabel=t.id" x-text="t.emoji + ' ' + t.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="photo-masonry">
|
||||
<template x-for="p in filteredPhotos()" :key="p.id">
|
||||
<article class="photo-tile" @click="openPhotoModal(p.id)">
|
||||
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
|
||||
<div class="photo-tile-body">
|
||||
<div class="photo-tile-head">
|
||||
<span class="photo-tile-id" x-text="'#' + p.id"></span>
|
||||
<span class="photo-tile-src" x-text="p.source || 'upload'"></span>
|
||||
</div>
|
||||
<div class="photo-tile-labels">
|
||||
<template x-for="lb in (p.user_labels || [])" :key="lb">
|
||||
<span class="doc-chip" x-text="labelDisplayName(lb)"></span>
|
||||
</template>
|
||||
<span class="doc-chip muted" x-show="!(p.user_labels || []).length">Nog geen label</span>
|
||||
</div>
|
||||
<p class="photo-tile-ocr" x-text="p.ocr_preview || 'Geen OCR tekst'"></p>
|
||||
<span class="photo-tile-meta" x-text="(p.box_count||0) + ' detecties · ' + (p.item_count||0) + ' items'"></span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!filteredPhotos().length" style="grid-column:1/-1;padding:2rem">Geen foto's — upload, Telegram of NAS OCR.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ DOCLING STUDIO ═══ -->
|
||||
<div data-tab-panel="docling" class="doc-workbench dl-workbench doc-panel">
|
||||
<div class="doc-wb-head">
|
||||
<div>
|
||||
<h2>Document Workbench</h2>
|
||||
<p x-text="doclingStatus"></p>
|
||||
<div class="dl-progress" x-show="doclingBusy"><div class="dl-progress-bar"></div></div>
|
||||
</div>
|
||||
<div class="dl-head-actions">
|
||||
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="doclingAutoConvert" /> Auto-convert bij klik</label>
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="shareBrowseRecursive" @change="browseShare(shareBrowsePath || '', false)" /> Incl. submappen</label>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="nasLoading || shareBrowseLoading" @click="loadAllNasFiles(true); browseShare(shareBrowsePath || '', true)"><span x-text="(nasLoading || shareBrowseLoading) ? 'Scannen…' : '↻ NAS'"></span></button>
|
||||
<button class="btn btn-secondary btn-sm" @click="dlOptionsOpen=!dlOptionsOpen" x-text="dlOptionsOpen ? 'Pipeline ▲' : 'Pipeline ▼'"></button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="doclingBusy || !doclingSelected" @click="runDoclingConvert()">↻ Convert</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="doclingBusy" @click="runDoclingBatch()">Batch</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-pipeline-panel" x-show="dlOptionsOpen">
|
||||
<div class="dl-pipeline-grid">
|
||||
<div>
|
||||
<p class="dl-section-title">Pipeline</p>
|
||||
<div class="dl-toggle-grid">
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.ocr" /> OCR</label>
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.tables" /> Tabellen</label>
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.page_images" /> Pagina's</label>
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.picture_images" /> Figuren</label>
|
||||
<label class="dl-toggle"><input type="checkbox" x-model="doclingOpts.force_full_page_ocr" /> Full-page OCR</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="dl-section-title">Export</p>
|
||||
<div class="dl-format-grid">
|
||||
<template x-for="fmt in doclingExportFormats" :key="fmt">
|
||||
<label class="dl-toggle" style="padding:0.35rem 0.5rem">
|
||||
<input type="checkbox" :value="fmt" x-model="doclingFormats" />
|
||||
<span x-text="fmt"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl-stats-grid" x-show="doclingResult">
|
||||
<div class="dl-stat"><div class="n" x-text="doclingMeta.page_count || 0"></div><div class="l">Pagina's</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="doclingTables.length"></div><div class="l">Tabellen</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="doclingPictures.length"></div><div class="l">Figuren</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="doclingAvailableExports().length"></div><div class="l">Exports</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-body-v3">
|
||||
<!-- Bestanden links -->
|
||||
<aside class="dl-sidebar">
|
||||
<p class="dl-section-title">Share <span x-text="'(' + (shareBrowseRecursive ? 'recursief · ' : '') + shareBrowsePath + ')'"></span></p>
|
||||
<nav class="share-breadcrumb" aria-label="Share pad">
|
||||
<button type="button" class="share-crumb" @click="browseShare('')">Share</button>
|
||||
<template x-for="(part, idx) in sharePathParts()" :key="'bc'+idx">
|
||||
<span class="share-crumb-sep">/</span>
|
||||
<button type="button" class="share-crumb" @click="browseShare(sharePathPrefix(idx))" x-text="part"></button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="dl-ext-chips">
|
||||
<template x-for="g in dlExtGroups" :key="g.ext || 'all'">
|
||||
<button type="button" class="dl-ext-chip" :class="doclingExtFilter===g.ext && 'active'" @click="doclingExtFilter=g.ext" x-text="(g.icon ? g.icon + ' ' : '') + g.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
<input type="search" class="form-input dl-search" placeholder="Zoek in map…" x-model="doclingFilter" />
|
||||
<div class="dl-file-list share-browser-list">
|
||||
<div class="dl-file-item share-folder-item" x-show="shareBrowsePath" @click="browseShareParent()">
|
||||
<span class="dl-file-icon">📁</span>
|
||||
<span class="dl-file-name">.. (omhoog)</span>
|
||||
</div>
|
||||
<template x-for="entry in shareBrowseFolders()" :key="'d'+entry.path">
|
||||
<div class="dl-file-item share-folder-item" @click="browseShare(entry.path)">
|
||||
<span class="dl-file-icon">📁</span>
|
||||
<div class="dl-file-meta">
|
||||
<span class="dl-file-name" x-text="entry.name"></span>
|
||||
<span class="dl-file-path" x-text="entry.path"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="f in doclingFilteredFiles()" :key="f.path">
|
||||
<div class="dl-file-item" :class="doclingSelected===f.path && 'selected'" @click.prevent="selectDoclingFile(f.path)">
|
||||
<div class="dl-file-row">
|
||||
<span class="dl-file-icon" x-text="fileIcon(f.path)"></span>
|
||||
<div class="dl-file-meta">
|
||||
<span class="dl-file-name" x-text="f.filename || f.path"></span>
|
||||
<span class="dl-file-path" x-text="f.path"></span>
|
||||
</div>
|
||||
<span class="dl-file-ext" x-text="fileExtension(f.path)"></span>
|
||||
</div>
|
||||
<div class="dl-file-tags">
|
||||
<span class="dl-file-ts" x-text="formatTs(f.modified_at)"></span>
|
||||
<span class="dl-file-badge" x-show="fileDoclingStatus(f.path)==='done'">✓</span>
|
||||
<span class="dl-file-badge pending" x-show="fileDoclingStatus(f.path)==='running'">…</span>
|
||||
<span class="dl-file-badge failed" x-show="fileDoclingStatus(f.path)==='failed'">✕</span>
|
||||
<span class="dl-file-badge convert" x-show="doclingSelected===f.path && doclingFileLoading">laden</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!shareBrowseFolders().length && !doclingFilteredFiles().length" style="font-size:0.72rem">Geen items — klik ↻ NAS of controleer Synology rechten.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Workspace rechts: editor + preview -->
|
||||
<div class="dl-workspace" :class="!doclingSelected && 'empty'">
|
||||
<div class="dl-workspace-inner" x-show="doclingSelected" style="display:flex;flex-direction:column;flex:1">
|
||||
<div class="dl-ws-head">
|
||||
<div class="dl-ws-title">
|
||||
<span class="dl-ws-icon" x-text="fileIcon(doclingSelected)"></span>
|
||||
<div>
|
||||
<strong x-text="doclingSelected.split('/').pop()"></strong>
|
||||
<span class="dl-ws-path" x-text="doclingSelected"></span>
|
||||
</div>
|
||||
<span class="dl-ws-kind" x-text="fileKindLabel(doclingSelected)"></span>
|
||||
<span class="dl-ws-ts" x-show="doclingFileMtime" x-text="'NAS: ' + formatTs(doclingFileMtime)"></span>
|
||||
</div>
|
||||
<div class="dl-version-bar" x-show="docVersions.length">
|
||||
<label style="font-size:0.72rem;color:#94a3b8">Versie</label>
|
||||
<select class="form-input" style="max-width:280px;font-size:0.72rem" @change="loadVersion($event.target.value)">
|
||||
<option value="">Huidige bewerking</option>
|
||||
<template x-for="v in docVersions" :key="v.id">
|
||||
<option :value="v.id" x-text="formatTs(v.created_at) + (v.version_label ? ' · ' + v.version_label : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dl-preview-tools">
|
||||
<button class="btn btn-sm btn-primary" :disabled="!doclingEditContent" @click="saveWorkspace()">Opslaan</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="!doclingEditContent" @click="saveToNas()">→ NAS</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="sendToPowerpoint()" x-show="doclingEditContent">→ PPT</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="copyDoclingExport()">Kopieer</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="!doclingResult" @click="downloadDoclingExport()">Download</button>
|
||||
<a class="btn btn-sm btn-secondary" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" target="_blank" rel="noopener">Open origineel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-loading-bar" x-show="doclingFileLoading || doclingBusy"><div class="dl-progress-bar"></div></div>
|
||||
|
||||
<div class="dl-workspace-panes">
|
||||
<!-- Editor -->
|
||||
<div class="dl-pane dl-pane-editor">
|
||||
<div class="dl-pane-label">Editor <span x-text="doclingFileKind === 'text' ? '(raw)' : '(markdown)'"></span></div>
|
||||
<textarea class="dl-editor dl-editor-full" x-model="doclingEditContent" :placeholder="doclingFileLoading ? 'Laden…' : 'Klik een bestand links — inhoud verschijnt hier…'" :disabled="doclingFileLoading"></textarea>
|
||||
</div>
|
||||
<!-- Preview -->
|
||||
<div class="dl-pane dl-pane-preview">
|
||||
<div class="dl-pane-label">
|
||||
Preview
|
||||
<div class="dl-export-tabs dl-export-tabs-inline" x-show="doclingResult">
|
||||
<template x-for="fmt in doclingAvailableExports()" :key="'t'+fmt">
|
||||
<button type="button" class="dl-tab" :class="doclingExportTab===fmt && 'active'" @click="doclingExportTab=fmt" x-text="fmt"></button>
|
||||
</template>
|
||||
<button type="button" class="dl-tab" :class="doclingExportTab==='rendered' && 'active'" @click="doclingExportTab='rendered'" x-show="doclingExports.markdown">md</button>
|
||||
<button type="button" class="dl-tab" :class="doclingExportTab==='original' && 'active'" @click="doclingExportTab='original'">orig</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl-preview-viewport">
|
||||
<img x-show="doclingFileKind==='image'" class="dl-preview-img" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'" :alt="doclingSelected" />
|
||||
<iframe x-show="doclingFileKind==='pdf' && doclingExportTab==='original'" class="dl-preview-iframe" :src="'/api/admin/documents/nas-file?path=' + encodeURIComponent(doclingSelected) + '&inline=1'"></iframe>
|
||||
<div x-show="doclingExportTab==='html' && doclingPreviewHtml()" class="dl-preview-box rendered" x-html="doclingPreviewHtml()"></div>
|
||||
<div x-show="doclingExportTab==='rendered' && doclingExports.markdown" class="dl-preview-box rendered" x-html="doclingRenderedMarkdown()"></div>
|
||||
<pre class="dl-preview-box" x-show="doclingResult && doclingExportTab!=='html' && doclingExportTab!=='rendered' && doclingExportTab!=='original' && doclingExportTab!=='tables' && doclingExportTab!=='pictures'" x-text="doclingPreviewText()"></pre>
|
||||
<p class="empty-state" x-show="!doclingResult && !doclingFileLoading && doclingFileKind!=='image' && doclingFileKind!=='pdf'" style="padding:2rem">Converteert… of geen preview beschikbaar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl-workspace-empty" x-show="!doclingSelected">
|
||||
<p class="empty-state" style="padding:3rem 1rem;text-align:center">
|
||||
<span style="font-size:2rem;display:block;margin-bottom:0.5rem">📂</span>
|
||||
Klik een bestand links om direct te bewerken.<br/>
|
||||
<span style="font-size:0.78rem;color:#64748b">PDF · Word · Excel · PowerPoint · tekst · afbeeldingen — auto-convert aan</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ POWERPOINT STUDIO ═══ -->
|
||||
<div data-tab-panel="powerpoint" class="doc-workbench ppt-workbench doc-panel">
|
||||
<div class="doc-wb-head">
|
||||
<div>
|
||||
<h2>PowerPoint Studio</h2>
|
||||
<p>Maak presentaties van NAS PDF's of bewerkte Docling-markdown</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" @click="loadAllNasFiles(false)">↻ Bestanden</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="pptBusy || !pptSlides.length" @click="createPptx()">Genereer PPTX</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ppt-grid">
|
||||
<aside class="ppt-sidebar">
|
||||
<p class="dl-section-title">Bron PDF (NAS)</p>
|
||||
<div class="dl-file-list">
|
||||
<template x-for="f in pptPdfFiles()" :key="'pdf'+f.path">
|
||||
<div class="dl-file-item" :class="pptSourcePdf===f.path && 'selected'" @click="pptSourcePdf=f.path">
|
||||
<span class="dl-file-name" x-text="f.filename"></span>
|
||||
<span class="dl-file-path" x-text="f.path"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" style="width:100%;margin-top:0.5rem" :disabled="!pptSourcePdf || pptBusy" @click="convertPdfToPptx()">PDF → PPTX (smart)</button>
|
||||
<p class="dl-section-title" style="margin-top:1rem">Bestaande PPTX</p>
|
||||
<div class="dl-file-list">
|
||||
<template x-for="f in pptPptxFiles()" :key="'ppt'+f.path">
|
||||
<div class="dl-file-item" :class="pptDownloadPath===f.path && 'selected'" @click.prevent="openInWorkbench(f.path)">
|
||||
<span class="dl-file-name" x-text="f.filename"></span>
|
||||
<span class="dl-file-path" x-text="f.path"></span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.25rem" @click.stop="openInWorkbench(f.path)">Open in Workbench</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="ppt-editor">
|
||||
<div class="ppt-meta-row">
|
||||
<input class="form-input" placeholder="Presentatie titel" x-model="pptTitle" />
|
||||
<input class="form-input" placeholder="Subtitel" x-model="pptSubtitle" />
|
||||
<input class="form-input" placeholder="Bestandsnaam (optioneel)" x-model="pptFilename" />
|
||||
</div>
|
||||
<div style="display:flex;gap:0.45rem;margin-bottom:0.75rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-secondary" @click="importMarkdownToSlides()">Importeer Docling markdown</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="addPptSlide()">+ Slide</button>
|
||||
</div>
|
||||
<div class="ppt-slides">
|
||||
<template x-for="(slide, idx) in pptSlides" :key="'slide'+idx">
|
||||
<div class="ppt-slide-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem">
|
||||
<strong style="font-size:0.78rem;color:#94a3b8">Slide <span x-text="idx+1"></span></strong>
|
||||
<button class="btn btn-sm btn-secondary" @click="removePptSlide(idx)">✕</button>
|
||||
</div>
|
||||
<input class="form-input" placeholder="Slide titel" x-model="slide.title" style="margin-bottom:0.35rem" />
|
||||
<textarea class="form-input" rows="4" placeholder="Bullets (één per regel)" x-model="slide.bulletsText" @input="syncSlideBullets(slide)"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!pptSlides.length">Voeg slides toe of importeer uit Docling.</p>
|
||||
</div>
|
||||
<p class="page-tip" x-show="pptLastOutput" style="margin-top:0.75rem">Laatste export: <span x-text="pptLastOutput"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ DOCUMENT CHAT (RAG) ═══ -->
|
||||
<div data-tab-panel="chat" class="doc-workbench doc-chat-workbench doc-panel">
|
||||
<div class="doc-wb-head">
|
||||
<div>
|
||||
<h2>Chat met je data</h2>
|
||||
<p>Herman + RAG over NAS-documenten · <span x-text="chatStatus"></span></p>
|
||||
<p class="page-tip" x-show="nasDiagHint" x-text="nasDiagHint" style="margin-top:0.35rem;color:#fbbf24"></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center">
|
||||
<select class="form-input" style="min-width:180px;font-size:0.75rem" x-model.number="chatLlmProviderId" @change="onChatProviderChange()">
|
||||
<option value="">Standaard LLM</option>
|
||||
<template x-for="p in llmProviders" :key="'llm'+p.id">
|
||||
<option :value="p.id" x-text="p.label + ' · ' + p.model + (p.is_default ? ' ★' : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
<a class="btn btn-sm btn-secondary" href="/settings?tab=ai" target="_blank">+ LLM API</a>
|
||||
<button class="btn btn-secondary btn-sm" @click="reindexNas()" :disabled="chatReindexing"><span x-text="chatReindexing ? 'Indexeren…' : '↻ Index NAS'"></span></button>
|
||||
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatUseHerman" /> Herman orchestrator (langzamer)</label>
|
||||
<button class="btn btn-secondary btn-sm" @click="runAutoSync()" :disabled="autoSyncBusy"><span x-text="autoSyncBusy ? 'Sync…' : '⟳ Second Brain'"></span></button>
|
||||
<label class="dl-toggle dl-auto-toggle"><input type="checkbox" x-model="chatScopeFile" /> Scope: bestand</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-chat-layout">
|
||||
<div class="doc-chat-messages" id="doc-chat-log">
|
||||
<template x-for="(m, i) in chatMessages" :key="'cm'+i">
|
||||
<div class="doc-chat-msg" :class="m.role">
|
||||
<span class="doc-chat-role" x-text="m.role === 'user' ? 'Jij' : (m.agent || 'Herman')"></span>
|
||||
<p x-text="m.content"></p>
|
||||
<div class="doc-chat-sources" x-show="m.sources && m.sources.length">
|
||||
<span style="font-size:0.62rem;color:#64748b">Bronnen:</span>
|
||||
<template x-for="s in m.sources" :key="s">
|
||||
<a class="doc-chat-src" :href="'/api/admin/documents/nas-file?path=' + encodeURIComponent(s)" target="_blank" x-text="s.split('/').pop()"></a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!chatMessages.length" style="padding:2rem;text-align:center">
|
||||
Stel een vraag over je documenten.<br/>
|
||||
<span style="font-size:0.78rem;color:#64748b">bv. "Wat staat er in de Cucina catalogue?" of "Samenvat halal certificaten"</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="doc-chat-side">
|
||||
<p class="dl-section-title">Snel zoeken</p>
|
||||
<input class="form-input dl-search" placeholder="Zoek in corpus…" x-model="chatSearchQ" @keydown.enter="runChatSearch()" />
|
||||
<button class="btn btn-sm btn-secondary" style="width:100%;margin-bottom:0.75rem" @click="runChatSearch()">Zoek</button>
|
||||
<div class="doc-chat-hits">
|
||||
<template x-for="h in chatSearchHits" :key="h.id || h.path">
|
||||
<div class="doc-chat-hit" @click="chatInput = 'Wat staat er in ' + (h.filename || h.path) + '?'">
|
||||
<strong x-text="h.filename || h.path"></strong>
|
||||
<p x-text="(h.text || '').slice(0, 120) + '…'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="dl-section-title" style="margin-top:1rem">Scope</p>
|
||||
<p class="page-tip" x-show="chatScopeClient && linkClientId" x-text="linkedClientName()"></p>
|
||||
<p class="page-tip" x-show="chatScopeFile && doclingSelected" x-text="'Bestand: ' + doclingSelected"></p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<form class="doc-chat-input" @submit.prevent="sendDocChat()">
|
||||
<input class="form-input" x-model="chatInput" placeholder="Vraag aan Herman over je documenten…" :disabled="chatBusy" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="chatBusy || !chatInput.trim()"><span x-text="chatBusy ? '…' : 'Stuur'"></span></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 360° KLANT ANALYTICS ═══ -->
|
||||
<div data-tab-panel="analytics360" class="doc-workbench analytics360-workbench doc-panel">
|
||||
<div class="doc-wb-head">
|
||||
<div>
|
||||
<h2>360° Klant Intelligence</h2>
|
||||
<p>Koppel NAS → klant/project · trends · second brain · <span x-text="autoSyncLabel"></span></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.45rem;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
|
||||
<button class="btn btn-primary btn-sm" @click="linkSelectedFile()" :disabled="!doclingSelected || !linkClientId">+ Koppel bestand</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="linkSelectedFolder()" :disabled="!doclingSelected || !linkClientId">+ Koppel map</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-toolbar">
|
||||
<select class="form-input" x-model.number="linkClientId" @change="loadClient360()">
|
||||
<option value="">— Klant —</option>
|
||||
<template x-for="c in linkClients" :key="'lc'+c.id">
|
||||
<option :value="c.id" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select class="form-input" x-model.number="linkProjectId">
|
||||
<option value="">— Project (optioneel) —</option>
|
||||
<template x-for="p in linkProjectsForClient()" :key="'lp'+p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="form-input" placeholder="NAS pad (of selecteer in Workbench)" x-model="linkPathInput" />
|
||||
</div>
|
||||
|
||||
<div class="a360-grid" x-show="linkClientId">
|
||||
<div class="a360-kpis">
|
||||
<div class="dl-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
|
||||
<div class="dl-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="dl-section-title">Gekoppelde paden</p>
|
||||
<div class="a360-links">
|
||||
<template x-for="l in client360.links || []" :key="'lnk'+l.id">
|
||||
<div class="a360-link-row">
|
||||
<span x-text="(l.is_folder ? '📁 ' : '📄 ') + l.storage_path"></span>
|
||||
<button class="btn btn-sm btn-secondary" @click="unlinkDocument(l.id)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!(client360.links||[]).length">Nog geen koppelingen — selecteer bestand in Workbench</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="dl-section-title">Sentiment & trends</p>
|
||||
<div class="a360-sentiment">
|
||||
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
|
||||
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="a360-trends">
|
||||
<template x-for="t in client360.monthly_trends || []" :key="t.month">
|
||||
<div class="a360-trend-row">
|
||||
<span x-text="(t.month||'').slice(0,7)"></span>
|
||||
<span x-text="t.docs + ' docs'"></span>
|
||||
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="dl-section-title">Top woorden (klant corpus)</p>
|
||||
<div class="a360-words">
|
||||
<template x-for="w in client360.top_words || []" :key="w.lemma">
|
||||
<span class="a360-word" x-text="w.lemma + ' (' + w.total + ')'"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="dl-section-title">Retail & deals</p>
|
||||
<template x-for="s in client360.stores || []" :key="'st'+s.id">
|
||||
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
|
||||
</template>
|
||||
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
|
||||
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="empty-state" x-show="!linkClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /doc-tab-stage -->
|
||||
|
||||
<!-- ═══ PHOTO LABEL MODAL ═══ -->
|
||||
<div class="label-modal-backdrop" :class="photoModalOpen && 'open'"
|
||||
@click.self="closePhotoModal()" @keydown.escape.window="closePhotoModal()">
|
||||
<div class="label-modal" @click.stop role="dialog" aria-labelledby="labelModalTitle">
|
||||
<div class="label-modal-head" style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h3 id="labelModalTitle">Wat voor label is deze foto?</h3>
|
||||
<p class="modal-sub">Foto #<span x-text="activePhoto"></span> — selecteer één of meer types</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="closePhotoModal()" aria-label="Sluiten">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="label-modal-preview" x-show="activePhoto">
|
||||
<img :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" alt="Preview" />
|
||||
<div>
|
||||
<div class="modal-ocr-label">OCR tekst</div>
|
||||
<pre class="modal-ocr-box" x-text="activeOcr || 'Geen tekst gedetecteerd'"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label-modal-grid">
|
||||
<template x-for="t in photoTaxonomy" :key="t.id">
|
||||
<button type="button" class="label-pick" :class="modalPhotoLabels.includes(t.id) && 'selected'"
|
||||
:style="'--pick-color:' + (t.color || '#00e5ff')" @click="toggleModalLabel(t.id)">
|
||||
<span class="label-pick-emoji" x-text="t.emoji"></span>
|
||||
<span class="label-pick-name" x-text="t.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="label-modal-extra">
|
||||
<div>
|
||||
<label>Extra label (optioneel)</label>
|
||||
<input type="text" class="form-input" x-model="photoLabelsInput" placeholder="bv. Q2-2026, Jumbo" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Notities</label>
|
||||
<textarea class="form-input" rows="2" x-model="photoNotesInput" placeholder="Toelichting…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label-modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="closePhotoModal()">Annuleren</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveModalPhotoLabels()">Label opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/doc-tabs.js?v=19"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overview",
|
||||
"cto_home": "CTO Overview",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Clients",
|
||||
"deals": "Deals",
|
||||
"products": "Products",
|
||||
"suppliers": "Suppliers",
|
||||
"intel": "Intel",
|
||||
"beurs": "Market Live",
|
||||
"retail": "Retail",
|
||||
"browser": "Browser",
|
||||
"documents": "Documents",
|
||||
"projects": "Projects",
|
||||
"hermes": "Hermes",
|
||||
"telegram": "Telegram",
|
||||
"agents": "Agents",
|
||||
"marketing": "Marketing",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"packaging": "Packaging",
|
||||
"reports": "Reports",
|
||||
"system": "System",
|
||||
"analytics": "Analytics",
|
||||
"voice": "Voice",
|
||||
"ops": "IT Ops",
|
||||
"settings": "Settings",
|
||||
"gitea": "Gitea"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading…",
|
||||
"none": "—",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"confirm_delete": "Are you sure you want to delete this?",
|
||||
"saved": "Saved",
|
||||
"deleted": "Deleted",
|
||||
"error": "Error",
|
||||
"all": "All",
|
||||
"actions": "Actions",
|
||||
"language": "Language",
|
||||
"dutch": "Nederlands",
|
||||
"english": "English"
|
||||
},
|
||||
"clients": {
|
||||
"title": "CRM · Clients",
|
||||
"subtitle": "Manage clients · pipeline · link stores · enrich branch data",
|
||||
"new": "New client",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "List",
|
||||
"tab_stores": "Supermarkets",
|
||||
"tab_360": "360° Client"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product catalog",
|
||||
"subtitle": "SKUs · margins · client links · launch planning",
|
||||
"new": "New product",
|
||||
"tab_grid": "Overview",
|
||||
"tab_list": "List",
|
||||
"tab_margin": "Margin calculator",
|
||||
"total": "Total products",
|
||||
"active": "Active",
|
||||
"avg_margin": "Avg. margin",
|
||||
"with_client": "With client",
|
||||
"name": "Product name",
|
||||
"status": "Status",
|
||||
"margin_pct": "Margin %",
|
||||
"moq": "MOQ",
|
||||
"shelf_target": "Shelf / listing",
|
||||
"launch_date": "Launch date",
|
||||
"client": "Client",
|
||||
"status_concept": "Concept",
|
||||
"status_development": "Development",
|
||||
"status_active": "Active",
|
||||
"status_discontinued": "Discontinued",
|
||||
"calc_cost": "Cost price",
|
||||
"calc_sell": "Sell price",
|
||||
"calc_profit": "Profit",
|
||||
"calc_margin": "Margin",
|
||||
"calc_run": "Calculate",
|
||||
"no_products": "No products yet — add your first SKU.",
|
||||
"drawer_deals": "Linked client"
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Suppliers",
|
||||
"subtitle": "Vendor registry · ratings · lead times · contacts",
|
||||
"new": "New supplier",
|
||||
"tab_grid": "Overview",
|
||||
"tab_list": "List",
|
||||
"total": "Total suppliers",
|
||||
"avg_rating": "Avg. rating",
|
||||
"countries": "Countries",
|
||||
"avg_lead": "Avg. lead time",
|
||||
"name": "Company name",
|
||||
"country": "Country",
|
||||
"category": "Category",
|
||||
"rating": "Rating",
|
||||
"moq": "MOQ",
|
||||
"lead_time": "Lead time (days)",
|
||||
"contact": "Contact",
|
||||
"cat_meat": "Meat",
|
||||
"cat_dairy": "Dairy",
|
||||
"cat_spices": "Spices",
|
||||
"cat_packaging": "Packaging",
|
||||
"cat_logistics": "Logistics",
|
||||
"cat_ingredients": "Ingredients",
|
||||
"cat_other": "Other",
|
||||
"no_suppliers": "No suppliers registered yet.",
|
||||
"filter_country": "Filter country",
|
||||
"filter_category": "Filter category"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cucina · Foodlinkk Command Center",
|
||||
"short_name": "Foodlinkk",
|
||||
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e14",
|
||||
"theme_color": "#0a0e14",
|
||||
"lang": "nl",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overzicht",
|
||||
"cto_home": "CTO Overzicht",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Klanten",
|
||||
"deals": "Deals",
|
||||
"products": "Producten",
|
||||
"suppliers": "Leveranciers",
|
||||
"intel": "Intel",
|
||||
"beurs": "Beurs Live",
|
||||
"retail": "Retail",
|
||||
"browser": "Browser",
|
||||
"documents": "Documenten",
|
||||
"projects": "Projecten",
|
||||
"hermes": "Hermes",
|
||||
"telegram": "Telegram",
|
||||
"agents": "Agents",
|
||||
"marketing": "Marketing",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"packaging": "Packaging",
|
||||
"reports": "Rapporten",
|
||||
"system": "Systeem",
|
||||
"analytics": "Analytics",
|
||||
"voice": "Voice",
|
||||
"ops": "IT Ops",
|
||||
"settings": "Instellingen",
|
||||
"gitea": "Gitea"
|
||||
},
|
||||
"common": {
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"add": "Toevoegen",
|
||||
"search": "Zoeken",
|
||||
"refresh": "Verversen",
|
||||
"loading": "Laden…",
|
||||
"none": "—",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
|
||||
"saved": "Opgeslagen",
|
||||
"deleted": "Verwijderd",
|
||||
"error": "Fout",
|
||||
"all": "Alle",
|
||||
"actions": "Acties",
|
||||
"language": "Taal",
|
||||
"dutch": "Nederlands",
|
||||
"english": "English"
|
||||
},
|
||||
"clients": {
|
||||
"title": "CRM · Klanten",
|
||||
"subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
|
||||
"new": "Nieuwe klant",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "Lijst",
|
||||
"tab_stores": "Supermarkten",
|
||||
"tab_360": "360° Klant"
|
||||
},
|
||||
"products": {
|
||||
"title": "Productcatalogus",
|
||||
"subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
|
||||
"new": "Nieuw product",
|
||||
"tab_grid": "Overzicht",
|
||||
"tab_list": "Lijst",
|
||||
"tab_margin": "Marge calculator",
|
||||
"total": "Totaal producten",
|
||||
"active": "Actief",
|
||||
"avg_margin": "Gem. marge",
|
||||
"with_client": "Met klant",
|
||||
"name": "Productnaam",
|
||||
"status": "Status",
|
||||
"margin_pct": "Marge %",
|
||||
"moq": "MOQ",
|
||||
"shelf_target": "Schap / listing",
|
||||
"launch_date": "Launch datum",
|
||||
"client": "Klant",
|
||||
"status_concept": "Concept",
|
||||
"status_development": "Ontwikkeling",
|
||||
"status_active": "Actief",
|
||||
"status_discontinued": "Stopgezet",
|
||||
"calc_cost": "Inkoopprijs",
|
||||
"calc_sell": "Verkoopprijs",
|
||||
"calc_profit": "Winst",
|
||||
"calc_margin": "Marge",
|
||||
"calc_run": "Berekenen",
|
||||
"no_products": "Nog geen producten — voeg je eerste SKU toe.",
|
||||
"drawer_deals": "Gekoppelde klant"
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Leveranciers",
|
||||
"subtitle": "Vendor registry · ratings · levertijden · contacten",
|
||||
"new": "Nieuwe leverancier",
|
||||
"tab_grid": "Overzicht",
|
||||
"tab_list": "Lijst",
|
||||
"total": "Totaal leveranciers",
|
||||
"avg_rating": "Gem. rating",
|
||||
"countries": "Landen",
|
||||
"avg_lead": "Gem. levertijd",
|
||||
"name": "Bedrijfsnaam",
|
||||
"country": "Land",
|
||||
"category": "Categorie",
|
||||
"rating": "Rating",
|
||||
"moq": "MOQ",
|
||||
"lead_time": "Levertijd (dagen)",
|
||||
"contact": "Contact",
|
||||
"cat_meat": "Vlees",
|
||||
"cat_dairy": "Zuivel",
|
||||
"cat_spices": "Kruiden & specerijen",
|
||||
"cat_packaging": "Verpakking",
|
||||
"cat_logistics": "Logistiek",
|
||||
"cat_ingredients": "Ingrediënten",
|
||||
"cat_other": "Overig",
|
||||
"no_suppliers": "Nog geen leveranciers geregistreerd.",
|
||||
"filter_country": "Filter land",
|
||||
"filter_category": "Filter categorie"
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ httpx==0.27.2
|
||||
websockets==13.1
|
||||
beautifulsoup4==4.12.3
|
||||
textblob==0.18.0.post0
|
||||
openpyxl>=3.1.0
|
||||
tweepy==4.15.0
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
/* Revenue Cockpit — Excel layout (licht) */
|
||||
.rc-excel-page {
|
||||
--rc-green: #92d050;
|
||||
--rc-red: #ff0000;
|
||||
--rc-orange: #ffc000;
|
||||
--rc-yellow: #ffff00;
|
||||
--rc-blue: #bdd7ee;
|
||||
--rc-border: #b4b4b4;
|
||||
--rc-text: #000;
|
||||
background: #e8e8e8;
|
||||
color: var(--rc-text);
|
||||
margin: -1rem -1.25rem 0;
|
||||
padding: 0.75rem 1rem 2rem;
|
||||
min-height: calc(100vh - 4rem);
|
||||
font-family: Calibri, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.persona-ceo .main-topnav:has(.rc-excel-page),
|
||||
.main-topnav:has(.rc-excel-page) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.rc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rc-toolbar-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.rc-save-hint { font-size: 0.72rem; color: #666; }
|
||||
.rc-saved { color: #217346; }
|
||||
.rc-btn {
|
||||
border: 1px solid #aaa;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
color: #222;
|
||||
}
|
||||
.rc-btn:hover { background: #f3f3f3; }
|
||||
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
|
||||
.rc-btn-import { font-size: 0.72rem; }
|
||||
|
||||
.rc-sheet-wrap {
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
|
||||
|
||||
.rc-sheet {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rc-col-logo { width: 72px; }
|
||||
.rc-col-deal { width: 16%; }
|
||||
.rc-col-num { width: 10%; }
|
||||
.rc-col-steps { width: auto; }
|
||||
|
||||
.rc-row td {
|
||||
border: 1px solid var(--rc-border);
|
||||
vertical-align: top;
|
||||
padding: 4px 6px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Header goals — geel */
|
||||
.rc-row-goals td {
|
||||
background: var(--rc-yellow);
|
||||
font-weight: 600;
|
||||
min-height: 48px;
|
||||
}
|
||||
.rc-cell-tagline {
|
||||
font-size: 14pt;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Kolomkoppen */
|
||||
.rc-row-head td {
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #666;
|
||||
}
|
||||
.rc-th { text-align: left; }
|
||||
.rc-num { text-align: right; }
|
||||
|
||||
/* Row kleuren — exact Excel */
|
||||
.rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
|
||||
.rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
|
||||
.rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
|
||||
.rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
|
||||
.rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
|
||||
.rc-style-white td:not(.rc-logo-cell) { background: #fff; }
|
||||
|
||||
.rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
|
||||
.rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
|
||||
|
||||
.rc-cell-edit:focus {
|
||||
outline: 2px solid #217346;
|
||||
outline-offset: -2px;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.rc-cell-deal { font-weight: 600; }
|
||||
.rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
|
||||
|
||||
/* Logo kolom */
|
||||
.rc-logo-cell {
|
||||
background: #fff !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 6px 4px;
|
||||
width: 72px;
|
||||
}
|
||||
.rc-logo-cucina .rc-logo-ring {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 6.5pt;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
.rc-logo-foodlinkk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.rc-fl-brand {
|
||||
font-size: 11pt;
|
||||
font-weight: 900;
|
||||
color: #111;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rc-fl-brand::first-letter { color: #f5c400; }
|
||||
.rc-fl-sub {
|
||||
font-size: 5.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #333;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Rij panel */
|
||||
.rc-row-panel {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.rc-row-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
|
||||
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
|
||||
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
|
||||
.rc-inline select, .rc-inline input {
|
||||
flex: 1;
|
||||
border: 1px solid #bbb;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.rc-style-select {
|
||||
border: 1px solid #bbb;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
}
|
||||
|
||||
.rc-footnote {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
|
||||
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
|
||||
}
|
||||
|
||||
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
|
||||
.rc-page {
|
||||
--rc-green: #4ade80;
|
||||
--rc-red: #f87171;
|
||||
--rc-orange: #fb923c;
|
||||
--rc-yellow: #facc15;
|
||||
--rc-blue: #60a5fa;
|
||||
--rc-green-fill: #22c55e;
|
||||
--rc-red-fill: #ef4444;
|
||||
--rc-orange-fill: #f59e0b;
|
||||
--rc-yellow-fill: #eab308;
|
||||
--rc-blue-fill: #3b82f6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.rc-page.rc-mode-sheet { background: transparent; }
|
||||
|
||||
.rc-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.rc-header h1 { margin: 0; font-size: 1.25rem; }
|
||||
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
|
||||
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
|
||||
|
||||
.rc-view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid rgba(148,163,184,0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-toggle-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
|
||||
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
|
||||
|
||||
.rc-summary-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: rgba(15,20,25,0.6);
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.rc-sum-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; }
|
||||
.rc-sum-item strong { font-size: 0.95rem; color: #34d399; font-variant-numeric: tabular-nums; }
|
||||
.rc-filter-inline {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148,163,184,0.18);
|
||||
background: #0a0e12;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
|
||||
.rc-dot-btn {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148,163,184,0.3);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
|
||||
.rc-dot-green { background: var(--rc-green); }
|
||||
.rc-dot-red { background: var(--rc-red); }
|
||||
.rc-dot-orange { background: var(--rc-orange); }
|
||||
.rc-dot-yellow { background: var(--rc-yellow); }
|
||||
.rc-dot-blue { background: var(--rc-blue); }
|
||||
.rc-dot-white { background: #e2e8f0; }
|
||||
|
||||
.rc-visual-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||
background: #0a0e12;
|
||||
}
|
||||
|
||||
.rc-visual-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: #0a0e12;
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.rc-col-chart-num { width: 11%; }
|
||||
.rc-col-donut { width: 64px; }
|
||||
|
||||
.rc-visual-goals td {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
color: #fde68a;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-vtagline { font-style: italic; text-align: center; color: #fbbf24; }
|
||||
|
||||
.rc-visual-head td {
|
||||
background: rgba(15,20,25,0.95);
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
border-bottom: 1px solid rgba(148,163,184,0.2);
|
||||
}
|
||||
|
||||
.rc-visual-row { cursor: pointer; transition: background 0.12s; }
|
||||
.rc-visual-row:hover td:not(.rc-logo-cell) { background: rgba(30,41,59,0.55) !important; }
|
||||
.rc-visual-row.rc-row-open td:not(.rc-logo-cell) { background: rgba(30,58,95,0.35) !important; }
|
||||
.rc-visual-row.rc-row-open { box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
|
||||
|
||||
.rc-visual-row td,
|
||||
.rc-vcell {
|
||||
border: 1px solid rgba(148,163,184,0.08);
|
||||
padding: 6px 10px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.35;
|
||||
background: rgba(15,20,25,0.5);
|
||||
}
|
||||
|
||||
.rc-visual-row.rc-style-green td:not(.rc-logo-cell) {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
box-shadow: inset 3px 0 0 var(--rc-green-fill);
|
||||
}
|
||||
.rc-visual-row.rc-style-red td:not(.rc-logo-cell) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
box-shadow: inset 3px 0 0 var(--rc-red-fill);
|
||||
}
|
||||
.rc-visual-row.rc-style-orange td:not(.rc-logo-cell) {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
box-shadow: inset 3px 0 0 var(--rc-orange-fill);
|
||||
}
|
||||
.rc-visual-row.rc-style-yellow td:not(.rc-logo-cell) {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
box-shadow: inset 3px 0 0 var(--rc-yellow-fill);
|
||||
font-weight: 600;
|
||||
color: #fde68a;
|
||||
}
|
||||
.rc-visual-row.rc-style-blue td:not(.rc-logo-cell) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
box-shadow: inset 3px 0 0 var(--rc-blue-fill);
|
||||
}
|
||||
.rc-visual-row.rc-style-white td:not(.rc-logo-cell) {
|
||||
background: rgba(15,20,25,0.5);
|
||||
box-shadow: inset 3px 0 0 #475569;
|
||||
}
|
||||
|
||||
.rc-brand-cucina .rc-vdeal { padding-left: 8px; }
|
||||
.rc-brand-foodlinkk .rc-vdeal { padding-left: 8px; }
|
||||
|
||||
.rc-logo-mini { width: 36px; padding: 2px !important; text-align: center; background: transparent !important; }
|
||||
.rc-logo-xs .rc-logo-ring { width: 26px; height: 26px; font-size: 11px; font-weight: 900; }
|
||||
.rc-logo-xs.rc-logo-foodlinkk .rc-fl-brand { font-size: 14px; }
|
||||
|
||||
.rc-vdeal { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #f1f5f9; }
|
||||
.rc-vnum { text-align: right; }
|
||||
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.8rem; font-weight: 600; color: #34d399; }
|
||||
|
||||
.rc-mini-bar {
|
||||
height: 4px;
|
||||
background: rgba(148,163,184,0.15);
|
||||
border-radius: 2px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-mini-bar-fill { height: 100%; border-radius: 2px; min-width: 2px; }
|
||||
.rc-mini-bar-year { background: #38bdf8 !important; }
|
||||
|
||||
.rc-vdonut { text-align: center; padding: 4px !important; }
|
||||
.rc-donut {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.rc-donut-label { font-size: 0.58rem; font-weight: 700; color: #e2e8f0; z-index: 1; }
|
||||
.rc-donut-empty { color: #64748b; font-size: 0.75rem; }
|
||||
|
||||
.rc-vsteps {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 0;
|
||||
}
|
||||
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 4px; }
|
||||
|
||||
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
|
||||
.rc-expand-cell { padding: 0 !important; }
|
||||
.rc-expand-inner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(10,14,18,0.98);
|
||||
color: #e2e8f0;
|
||||
border-top: 1px solid rgba(56,189,248,0.15);
|
||||
}
|
||||
.rc-expand-chart { flex: 0 0 140px; }
|
||||
.rc-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 72px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
|
||||
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
|
||||
.rc-bar-stack {
|
||||
width: 28px;
|
||||
min-height: 2px;
|
||||
background: var(--rc-green-fill);
|
||||
border-radius: 3px 3px 0 0;
|
||||
transition: height 0.2s;
|
||||
}
|
||||
.rc-bar-year { background: #38bdf8; }
|
||||
.rc-expand-stats { font-size: 0.75rem; }
|
||||
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.rc-expand-stats span { color: #64748b; }
|
||||
.rc-expand-stats strong { color: #34d399; }
|
||||
.rc-expand-body { flex: 1; min-width: 0; }
|
||||
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
|
||||
.rc-expand-body p { margin: 0; font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; color: #cbd5e1; }
|
||||
.rc-expand-task { margin-top: 0.65rem; }
|
||||
.rc-expand-task .rc-inline select,
|
||||
.rc-expand-task .rc-inline input {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #0f1419;
|
||||
color: #e2e8f0;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rc-page .rc-excel-page {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
background: #e8e8e8;
|
||||
color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-summary-strip { gap: 0.5rem; }
|
||||
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
|
||||
.rc-expand-inner { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, rgba(148,163,184,0.15) ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=5" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
|
||||
<header class="rc-header">
|
||||
<div>
|
||||
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
|
||||
<p class="rc-subtitle" x-show="meta.file_mtime">
|
||||
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
|
||||
· <span x-text="fmtDate(meta.file_mtime)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rc-header-actions">
|
||||
<div class="rc-view-toggle">
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
|
||||
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Cockpit: Excel-layout met charts -->
|
||||
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
|
||||
|
||||
<div class="rc-summary-strip">
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / mnd</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / jr</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Deals</span>
|
||||
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
|
||||
</div>
|
||||
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
|
||||
<div class="rc-style-dots">
|
||||
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
|
||||
<template x-for="s in styleFilters" :key="'d-' + s.id">
|
||||
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-wrap">
|
||||
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-logo" />
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-donut" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
|
||||
<!-- Excel rij 1: goals -->
|
||||
<tr class="rc-visual-goals">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
|
||||
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-visual-head">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-vcell">Deal</td>
|
||||
<td class="rc-vcell rc-num">Marge mnd</td>
|
||||
<td class="rc-vcell rc-num">Marge jr</td>
|
||||
<td class="rc-vcell rc-num">Aandeel</td>
|
||||
<td class="rc-vcell">Next steps</td>
|
||||
</tr>
|
||||
|
||||
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
|
||||
<tr class="rc-visual-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
<td class="rc-logo-cell rc-logo-mini">
|
||||
<template x-if="row.logoCell === 'cucina'">
|
||||
<div class="rc-logo rc-logo-cucina rc-logo-xs"><span class="rc-logo-ring">C</span></div>
|
||||
</template>
|
||||
<template x-if="row.logoCell === 'foodlinkk'">
|
||||
<div class="rc-logo rc-logo-foodlinkk rc-logo-xs"><span class="rc-fl-brand">F</span></div>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdeal" x-text="row.name"></td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_month">
|
||||
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_year">
|
||||
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdonut">
|
||||
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
|
||||
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vsteps">
|
||||
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
|
||||
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
|
||||
<td class="rc-expand-cell" colspan="6">
|
||||
<div class="rc-expand-inner">
|
||||
<div class="rc-expand-chart">
|
||||
<div class="rc-bar-chart">
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-stats">
|
||||
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
|
||||
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
|
||||
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-body">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
|
||||
<div class="rc-expand-task" x-show="row.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerken: originele Excel -->
|
||||
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
|
||||
<div class="rc-toolbar">
|
||||
<span class="rc-toolbar-title">Bewerken · DB</span>
|
||||
<div class="rc-toolbar-actions">
|
||||
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
|
||||
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
|
||||
<table class="rc-sheet" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-logo" />
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
<tr class="rc-row rc-row-goals">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.vision_text || ''"
|
||||
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.horizon_text || ''"
|
||||
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.mid_text || ''"
|
||||
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
|
||||
x-init="$el.textContent = goals.tagline || ''"
|
||||
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
|
||||
</tr>
|
||||
<tr class="rc-row rc-row-head">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-th">Deal</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Month</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Year</td>
|
||||
<td class="rc-cell rc-th">Next steps</td>
|
||||
</tr>
|
||||
<template x-for="row in sheetRows" :key="row.id || row.source_row">
|
||||
<tr class="rc-row rc-data-row"
|
||||
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
|
||||
@click="selectRow(row)">
|
||||
<template x-if="row.logoCell === 'cucina'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="row.logoCell === 'foodlinkk'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-foodlinkk">
|
||||
<span class="rc-fl-brand">Foodlinkk</span>
|
||||
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.name || ''"
|
||||
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_month)"
|
||||
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_year)"
|
||||
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.next_steps || ''"
|
||||
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
|
||||
<div class="rc-row-panel-head">
|
||||
<strong x-text="selectedRow?.name"></strong>
|
||||
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
|
||||
<option value="green">Groen (live)</option>
|
||||
<option value="red">Rood (inactief)</option>
|
||||
<option value="orange">Oranje</option>
|
||||
<option value="yellow">Geel (sectie)</option>
|
||||
<option value="blue">Blauw</option>
|
||||
<option value="white">Wit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rc-row-panel-body">
|
||||
<label>Agent taak
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
|
||||
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=4"></script>
|
||||
{% endblock %}
|
||||
@@ -1,103 +1,517 @@
|
||||
.mesh-wrap {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(180deg, rgba(7, 11, 18, 0.95) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.mesh-wrap-mode-mesh { border-color: rgba(168, 85, 247, 0.25); }
|
||||
.mesh-wrap-mode-org { border-color: rgba(255, 215, 0, 0.2); }
|
||||
.mesh-wrap-mode-pulse { border-color: rgba(34, 197, 94, 0.25); }
|
||||
.mesh-wrap-mode-clusters { border-color: rgba(255, 159, 67, 0.25); }
|
||||
.mesh-wrap-mode-pyramid { border-color: rgba(0, 229, 255, 0.2); }
|
||||
|
||||
.mesh-svg {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
min-height: 680px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mesh-tier-band {
|
||||
fill: rgba(148, 163, 184, 0.03);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.mesh-tier-0 { fill: rgba(0, 229, 255, 0.03); }
|
||||
.mesh-tier-1 { fill: rgba(168, 85, 247, 0.025); }
|
||||
.mesh-tier-2 { fill: rgba(34, 197, 94, 0.025); }
|
||||
|
||||
.mesh-edge {
|
||||
stroke: rgba(0, 229, 255, 0.42);
|
||||
stroke-width: 1.6;
|
||||
fill: none;
|
||||
stroke: rgba(100, 116, 139, 0.2);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-edge-report {
|
||||
stroke: rgba(0, 229, 255, 0.45);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.mesh-edge-delegate {
|
||||
stroke: rgba(255, 215, 0, 0.4);
|
||||
stroke-width: 1.3;
|
||||
stroke-dasharray: 5 6;
|
||||
}
|
||||
|
||||
.mesh-edge-executive {
|
||||
stroke: rgba(255, 159, 67, 0.28);
|
||||
stroke-width: 1.1;
|
||||
}
|
||||
|
||||
.mesh-edge-peer {
|
||||
stroke: rgba(168, 85, 247, 0.12);
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
|
||||
.mesh-edge-peer-live {
|
||||
stroke: rgba(192, 132, 252, 0.55);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.mesh-pulse {
|
||||
stroke: rgba(0, 229, 255, 0.9);
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 3 140;
|
||||
animation: mesh-pulse 2.4s linear infinite;
|
||||
stroke-dasharray: 4 100;
|
||||
animation: mesh-pulse 2.6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-pulse-active.mesh-edge-report { stroke: rgba(0, 229, 255, 0.85); }
|
||||
.mesh-pulse-active.mesh-edge-delegate { stroke: rgba(255, 215, 0, 0.75); }
|
||||
.mesh-pulse-active.mesh-edge-peer-live { stroke: rgba(192, 132, 252, 0.9); }
|
||||
|
||||
@keyframes mesh-pulse {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -286; }
|
||||
to { stroke-dashoffset: -208; }
|
||||
}
|
||||
|
||||
.mesh-node { cursor: pointer; }
|
||||
.mesh-node text {
|
||||
font-size: 12px;
|
||||
fill: #e2e8f0;
|
||||
font-weight: 600;
|
||||
.mesh-node text.mesh-label {
|
||||
font-size: 13px;
|
||||
fill: #f1f5f9;
|
||||
font-weight: 700;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-label-agent { font-size: 13px; }
|
||||
.mesh-label-herman { font-size: 14px; fill: #ffd700; }
|
||||
.mesh-label-exec { font-size: 13px; }
|
||||
.mesh-node text.mesh-sublabel {
|
||||
font-size: 10px;
|
||||
fill: #94a3b8;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-sublabel-herman { fill: #cbd5e1; font-size: 10px; }
|
||||
.mesh-node text.mesh-context {
|
||||
font-size: 9.5px;
|
||||
fill: #64748b;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-node text.mesh-domain {
|
||||
font-size: 8.5px;
|
||||
fill: #475569;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mesh-node text.mesh-emoji {
|
||||
font-size: 14px;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-herman-crown { font-size: 18px; pointer-events: none; }
|
||||
|
||||
.mesh-node circle {
|
||||
fill: #0f172a;
|
||||
stroke: rgba(148, 163, 184, 0.65);
|
||||
.mesh-node circle.main {
|
||||
fill: #0b1220;
|
||||
stroke: rgba(148, 163, 184, 0.55);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mesh-node.healthy circle { stroke: #22c55e; }
|
||||
.mesh-node.warn circle { stroke: #f59e0b; }
|
||||
.mesh-node.idle circle { stroke: #38bdf8; }
|
||||
.mesh-node.offline circle { stroke: #64748b; }
|
||||
.mesh-node.healthy circle.main { stroke: #22c55e; }
|
||||
.mesh-node.warn circle.main { stroke: #f59e0b; }
|
||||
.mesh-node.idle circle.main { stroke: #475569; }
|
||||
.mesh-node.offline circle.main { stroke: #334155; opacity: 0.65; }
|
||||
|
||||
.mesh-herman circle.main {
|
||||
fill: #111827;
|
||||
stroke: #ffd700;
|
||||
stroke-width: 4;
|
||||
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
|
||||
}
|
||||
|
||||
.mesh-exec circle.main {
|
||||
fill: #0f172a;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
|
||||
.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
|
||||
|
||||
.mesh-exec circle.ring {
|
||||
.mesh-agent-live .mesh-pulse-wrap circle.pulse-ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.15);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.mesh-agent .mesh-status { fill: #64748b; }
|
||||
|
||||
.mesh-herman circle.ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 215, 0, 0.35);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 8 4;
|
||||
animation: mesh-herman-ring 7s linear infinite;
|
||||
stroke: rgba(34, 197, 94, 0.45);
|
||||
stroke-width: 1.8;
|
||||
animation: mesh-agent-pulse 2.8s ease-in-out infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes mesh-herman-ring {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@keyframes mesh-agent-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 0.75; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
.mesh-herman circle.main {
|
||||
fill: #0f172a;
|
||||
stroke: #ffd700;
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
|
||||
}
|
||||
|
||||
.mesh-herman.active .mesh-herman-ring circle.ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 215, 0, 0.4);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 10 8;
|
||||
}
|
||||
|
||||
.mesh-status-live circle.main { stroke-width: 2.5; }
|
||||
.mesh-status-ready circle.main { stroke: #64748b; }
|
||||
.mesh-status-warn circle.main { stroke: #f59e0b; }
|
||||
.mesh-status-idle circle.main { stroke: #334155; opacity: 0.7; }
|
||||
|
||||
.mesh-exec circle.main { stroke-width: 2.5; }
|
||||
.mesh-ceo circle.main { stroke: #ff9f43; }
|
||||
.mesh-cto circle.main { stroke: #a855f7; }
|
||||
|
||||
/* ——— Communicatie-modus kiezer ——— */
|
||||
.mesh-mode-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mesh-mode-section > label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mesh-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mesh-mode-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.mesh-mode-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.mesh-mode-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 0.75rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(145deg, rgba(15, 23, 42, 0.7) 0%, rgba(7, 11, 18, 0.9) 100%);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
min-height: 5.5rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mesh-mode-card:hover {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mesh-mode-card.is-active {
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 4px 20px rgba(0, 229, 255, 0.08);
|
||||
background: linear-gradient(145deg, rgba(0, 229, 255, 0.06) 0%, rgba(7, 11, 18, 0.95) 100%);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="mesh"].is-active {
|
||||
border-color: rgba(168, 85, 247, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15), 0 4px 20px rgba(168, 85, 247, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="org"].is-active {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.12), 0 4px 20px rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="pulse"].is-active {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 4px 20px rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="clusters"].is-active {
|
||||
border-color: rgba(255, 159, 67, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(255, 159, 67, 0.12), 0 4px 20px rgba(255, 159, 67, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-icon {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mesh-mode-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mesh-mode-sub {
|
||||
font-size: 0.68rem;
|
||||
color: #64748b;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mesh-mode-desc {
|
||||
font-size: 0.65rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.15rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mesh-mode-check {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(148, 163, 184, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.mesh-mode-card.is-active .mesh-mode-check {
|
||||
opacity: 1;
|
||||
background: #00e5ff;
|
||||
border-color: #00e5ff;
|
||||
box-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
|
||||
}
|
||||
|
||||
.mesh-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.mesh-mode-badge {
|
||||
margin-right: auto;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #00e5ff;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.mesh-framework-head { margin-bottom: 0.5rem; }
|
||||
.mesh-framework-head h2 { margin: 0; }
|
||||
|
||||
.mesh-legend {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
gap: 0.65rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 0.65rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mesh-legend span::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mesh-legend .leg-report::before { background: #00e5ff; }
|
||||
.mesh-legend .leg-delegate::before { background: #ffd700; }
|
||||
.mesh-legend .leg-peer::before { background: #a855f7; }
|
||||
.mesh-legend .leg-exec::before { background: #ff9f43; }
|
||||
|
||||
.mesh-cluster-label {
|
||||
font-size: 11px;
|
||||
fill: #94a3b8;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-agent-cluster-growth circle.main { stroke: rgba(0, 229, 255, 0.7); }
|
||||
.mesh-agent-cluster-product circle.main { stroke: rgba(168, 85, 247, 0.7); }
|
||||
.mesh-agent-cluster-ops circle.main { stroke: rgba(34, 197, 94, 0.65); }
|
||||
|
||||
.mesh-edge-straight { stroke-dasharray: none; }
|
||||
|
||||
/* ——— Terminals grid ——— */
|
||||
.agents-terminals-intro {
|
||||
margin: 0 0 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.agents-terminals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.agent-term-window {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 12px;
|
||||
background: #060a10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.agent-term-window.is-live {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.agent-term-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.agent-term-head .term-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-term-window.is-live .term-dot {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
|
||||
animation: term-dot-blink 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes term-dot-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.agent-term-head strong { color: #f1f5f9; flex: 1; }
|
||||
.agent-term-head small { color: #64748b; font-size: 0.72rem; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.agent-term-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-body {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.term-line {
|
||||
padding: 0.2rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.term-ts { color: #64748b; flex-shrink: 0; min-width: 2.5rem; }
|
||||
.term-msg { color: #cbd5e1; word-break: break-word; }
|
||||
.term-line.line-handoff .term-msg { color: #c4b5fd; }
|
||||
.term-line.line-action .term-msg { color: #86efac; }
|
||||
.term-line.line-command .term-msg { color: #86efac; font-weight: 600; }
|
||||
.term-line.line-output .term-msg { color: #e2e8f0; }
|
||||
.term-line.line-error .term-msg { color: #fca5a5; }
|
||||
.term-line.term-idle .term-msg { color: #64748b; font-style: italic; }
|
||||
.term-line.term-idle code { color: #94a3b8; font-size: 0.95em; }
|
||||
|
||||
.agent-term-foot {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(10, 14, 20, 0.95);
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.agent-term-cmd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
color: #22c55e;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-term-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #f1f5f9;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
outline: none;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.agent-term-input::placeholder { color: #475569; }
|
||||
.agent-term-input.is-busy { opacity: 0.55; }
|
||||
|
||||
.agent-term-window.is-running .term-dot {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-foot {
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-input {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mesh-terminal-focus {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
border-radius: 12px;
|
||||
background: #060a10;
|
||||
}
|
||||
|
||||
.mesh-terminal-focus .agent-term-body {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.vtab-badge {
|
||||
display: inline-flex;
|
||||
min-width: 1.1rem;
|
||||
|
||||
@@ -0,0 +1,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); }
|
||||
@@ -11,15 +11,60 @@
|
||||
.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
|
||||
|
||||
.clients-tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
|
||||
margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.clients-tab-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.88);
|
||||
border: 1px solid rgba(56, 189, 248, 0.14);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.clients-tab {
|
||||
border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
|
||||
color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.clients-tab-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.clients-tab:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(56, 189, 248, 0.07);
|
||||
}
|
||||
.clients-tab.active {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(168, 85, 247, 0.1));
|
||||
border-color: rgba(56, 189, 248, 0.38);
|
||||
box-shadow: 0 0 18px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
.clients-tab.active .clients-tab-icon { opacity: 1; }
|
||||
.clients-tab-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
|
||||
@media (max-width: 720px) {
|
||||
.clients-tabs { flex-direction: column; align-items: stretch; }
|
||||
.clients-tab-bar { width: 100%; }
|
||||
.clients-tab-actions { margin-left: 0; width: 100%; }
|
||||
}
|
||||
.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
|
||||
.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
|
||||
@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
|
||||
@@ -39,7 +84,30 @@
|
||||
.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
|
||||
.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
|
||||
.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
|
||||
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
|
||||
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; margin-bottom: 0.45rem; }
|
||||
.clients-card-stage {
|
||||
width: 100%;
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.28rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.clients-card-stage:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15); }
|
||||
.clients-inline-stage {
|
||||
min-width: 7.5rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.clients-inline-stage:focus { outline: none; border-color: #38bdf8; }
|
||||
.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
|
||||
.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
|
||||
.stage-intake { border-left: 3px solid #94a3b8; }
|
||||
@@ -76,6 +144,103 @@
|
||||
display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
|
||||
|
||||
.store-filters-card {
|
||||
background: rgba(13, 18, 25, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.store-filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .store-filters-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 640px) { .store-filters-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.store-filter-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.store-filter-field .filter-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-filter-search { grid-column: span 2; }
|
||||
@media (max-width: 640px) { .store-filter-search { grid-column: span 2; } }
|
||||
.store-filters-extra {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.store-filters-extra summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: #7dd3fc;
|
||||
user-select: none;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.store-filter-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.store-filter-checks label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.store-filters-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.store-results-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-results-col { min-width: 0; }
|
||||
.store-table-wrap {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.store-results-table { font-size: 0.78rem; margin: 0; }
|
||||
.store-results-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #0d1219;
|
||||
z-index: 1;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-table-row { cursor: pointer; }
|
||||
.store-table-row:hover td { background: rgba(56, 189, 248, 0.06); }
|
||||
.store-table-row.selected td {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
box-shadow: inset 3px 0 0 #38bdf8;
|
||||
}
|
||||
.store-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
|
||||
.store-list-item {
|
||||
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
|
||||
@@ -91,3 +256,51 @@
|
||||
.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
|
||||
|
||||
.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
/* ── 360° Klant (CRM) ── */
|
||||
.crm-360-shell {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
|
||||
overflow: hidden;
|
||||
min-height: 560px;
|
||||
}
|
||||
.crm-360-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.06), transparent 70%);
|
||||
}
|
||||
.crm-360-head h2 { margin: 0; font-size: 1.05rem; color: #f1f5f9; }
|
||||
.crm-360-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
|
||||
.crm-360-section {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.crm-360-stat {
|
||||
padding: 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
}
|
||||
.crm-360-stat .n { font-size: 1.2rem; font-weight: 800; color: #f1f5f9; }
|
||||
.crm-360-stat .l { font-size: 0.6rem; color: #64748b; text-transform: uppercase; }
|
||||
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
|
||||
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
|
||||
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
|
||||
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
|
||||
.a360-links { max-height: 200px; overflow-y: auto; }
|
||||
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); color: #cbd5e1; }
|
||||
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(56,189,248,0.12); color: #7dd3fc; }
|
||||
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
|
||||
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
/* Documents & NAS Workbench v2 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.doc-hub {
|
||||
--doc-accent: #00e5ff;
|
||||
--doc-gold: #ffd700;
|
||||
--doc-purple: #a855f7;
|
||||
--doc-border: rgba(0, 229, 255, 0.14);
|
||||
--doc-text: #f1f5f9;
|
||||
--doc-muted: #94a3b8;
|
||||
--doc-dim: #64748b;
|
||||
}
|
||||
|
||||
.doc-hub .page-header h1 {
|
||||
font-size: 1.65rem;
|
||||
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.85);
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.doc-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
transition: all 0.18s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
|
||||
|
||||
.doc-nav-btn.active,
|
||||
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
|
||||
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
|
||||
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"] {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Workbench shell */
|
||||
.doc-workbench {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--doc-border);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
|
||||
.doc-tab-input {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#doc-tab-stage {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
#doc-tab-stage [data-tab-panel] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
|
||||
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
|
||||
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
|
||||
#doc-tab-stage [data-tab-panel].tab-active {
|
||||
display: block !important;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
|
||||
font-size: 0.65rem;
|
||||
color: var(--doc-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-version-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.doc-row-fallback { opacity: 0.85; }
|
||||
|
||||
.share-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.share-crumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
|
||||
.share-crumb-sep { color: #475569; }
|
||||
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
|
||||
.share-browser-list { max-height: 52vh; overflow-y: auto; }
|
||||
|
||||
.doc-wb-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
.doc-wb-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
|
||||
|
||||
.doc-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.doc-kpi {
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.doc-kpi:last-child { border-right: none; }
|
||||
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
|
||||
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
|
||||
|
||||
.doc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
|
||||
|
||||
/* Split layout */
|
||||
.doc-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
|
||||
|
||||
.doc-split-col {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-split-col:last-child { border-right: none; }
|
||||
|
||||
.doc-chart-wrap { height: 140px; position: relative; }
|
||||
|
||||
/* Document table */
|
||||
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
|
||||
|
||||
.doc-table-wrap .data-table { font-size: 0.78rem; }
|
||||
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
|
||||
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
|
||||
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
|
||||
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
|
||||
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
|
||||
|
||||
/* Label chips */
|
||||
.doc-chip {
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.55rem;
|
||||
margin: 0.12rem 0.12rem 0 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
|
||||
|
||||
/* Photo grid */
|
||||
.photo-masonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.photo-tile {
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(10, 15, 26, 0.95);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.photo-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.photo-tile img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
background: #060a10;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-tile-body {
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.photo-tile-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
|
||||
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.photo-tile-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
min-height: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.photo-tile-ocr {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
color: var(--doc-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.8em;
|
||||
}
|
||||
|
||||
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
|
||||
|
||||
/* Label filter chips row */
|
||||
.label-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.label-filter-chip {
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.label-filter-chip.active, .label-filter-chip:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
color: #e0f2fe;
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── Label modal (fixed overlap + dark text) ── */
|
||||
.label-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.label-modal-backdrop.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label-modal {
|
||||
width: min(780px, 100%);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 1.35rem;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
|
||||
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
|
||||
|
||||
.label-modal-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
|
||||
|
||||
.label-modal-preview img {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
max-height: 160px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.modal-ocr-box {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1 !important;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-ocr-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.label-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-height: 92px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.label-pick:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick.selected {
|
||||
border-color: var(--pick-color, #00e5ff);
|
||||
background: rgba(0, 229, 255, 0.14);
|
||||
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
|
||||
|
||||
.label-pick-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.label-pick.selected .label-pick-name { color: #f8fafc; }
|
||||
|
||||
.label-modal-extra {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-modal-extra label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--doc-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.label-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
/* Document label drawer */
|
||||
.doc-label-drawer {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
|
||||
|
||||
/* ── Docling workbench ── */
|
||||
.dl-workbench { min-height: 620px; }
|
||||
|
||||
.dl-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 300px 1fr;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
/* v3 split workbench: files | editor+preview */
|
||||
.dl-body-v3 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) 1fr;
|
||||
min-height: 580px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
|
||||
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
|
||||
|
||||
.dl-pipeline-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dl-pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 580px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dl-workspace.empty { justify-content: center; align-items: center; }
|
||||
|
||||
.dl-workspace-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-ws-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
|
||||
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
|
||||
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
|
||||
|
||||
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
|
||||
|
||||
.dl-workspace-panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
}
|
||||
|
||||
.dl-pane:last-child { border-right: none; }
|
||||
|
||||
.dl-pane-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-export-tabs-inline { margin: 0; }
|
||||
|
||||
.dl-editor-full {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.dl-preview-viewport {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-viewport .dl-preview-box {
|
||||
min-height: 200px;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dl-preview-iframe {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
|
||||
.dl-file-meta { flex: 1; min-width: 0; }
|
||||
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
|
||||
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
|
||||
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
|
||||
|
||||
.dl-body-v3 .dl-sidebar {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body-v3 { grid-template-columns: 1fr; }
|
||||
.dl-body-v3 .dl-sidebar { max-height: 280px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body { grid-template-columns: 240px 1fr; }
|
||||
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
|
||||
}
|
||||
|
||||
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
overflow-y: auto;
|
||||
max-height: 680px;
|
||||
}
|
||||
|
||||
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
|
||||
.dl-options { background: rgba(0, 0, 0, 0.12); }
|
||||
|
||||
.dl-section-title {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--doc-dim);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-ext-chip {
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.dl-file-item {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.dl-file-item.selected {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dl-file-badge {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
|
||||
|
||||
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
font-size: 0.76rem;
|
||||
color: var(--doc-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.dl-toggle:has(input:checked) {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
color: #e0f2fe;
|
||||
}
|
||||
|
||||
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
|
||||
|
||||
.dl-preview-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
|
||||
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-tab {
|
||||
padding: 0.32rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-preview-box {
|
||||
min-height: 320px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dl-preview-box.rendered { white-space: normal; }
|
||||
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
|
||||
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
|
||||
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
|
||||
|
||||
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
|
||||
|
||||
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-stat {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
|
||||
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.dl-history-item {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
|
||||
|
||||
.dl-progress {
|
||||
height: 3px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dl-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00e5ff, #a855f7);
|
||||
animation: dl-pulse 1.2s ease-in-out infinite;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.dl-editor {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ppt-workbench { min-height: 560px; }
|
||||
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
|
||||
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
|
||||
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
|
||||
.ppt-editor { padding: 1rem; }
|
||||
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
|
||||
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
|
||||
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
|
||||
|
||||
/* ── Document chat (RAG) ── */
|
||||
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
|
||||
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
|
||||
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
|
||||
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
|
||||
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
|
||||
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
|
||||
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
|
||||
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
|
||||
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
|
||||
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
|
||||
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
|
||||
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
|
||||
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
|
||||
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
|
||||
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
.doc-chat-input .form-input { flex: 1; }
|
||||
|
||||
@keyframes dl-pulse {
|
||||
0%, 100% { transform: translateX(-100%); width: 30%; }
|
||||
50% { transform: translateX(200%); width: 50%; }
|
||||
}
|
||||
|
||||
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
@@ -0,0 +1,51 @@
|
||||
.doc-tabs { display:flex; gap:0.35rem; flex-wrap:wrap; margin-bottom:1rem; }
|
||||
.doc-tab {
|
||||
padding:0.45rem 0.9rem; border-radius:999px; font-size:0.78rem; cursor:pointer;
|
||||
border:1px solid rgba(0,229,255,0.15); background:rgba(13,18,25,0.8); color:#94a3b8;
|
||||
}
|
||||
.doc-tab.active { background:rgba(0,229,255,0.15); color:#e0f2fe; border-color:rgba(0,229,255,0.45); }
|
||||
|
||||
.label-modal-backdrop {
|
||||
position:fixed; inset:0; z-index:1200; background:rgba(0,0,0,0.72);
|
||||
display:flex; align-items:center; justify-content:center; padding:1rem;
|
||||
}
|
||||
.label-modal {
|
||||
width:min(720px, 100%); max-height:90vh; overflow:auto;
|
||||
background:linear-gradient(160deg,#0d1219,#080c12); border:1px solid rgba(0,229,255,0.2);
|
||||
border-radius:16px; padding:1.1rem; box-shadow:0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.label-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.85rem; }
|
||||
.label-modal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:0.45rem; }
|
||||
.label-pick {
|
||||
display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.65rem 0.4rem;
|
||||
border-radius:12px; border:1px solid rgba(100,116,139,0.35); background:rgba(15,23,42,0.6);
|
||||
cursor:pointer; font-size:0.72rem; text-align:center; transition:all .15s;
|
||||
}
|
||||
.label-pick:hover { border-color:rgba(0,229,255,0.35); }
|
||||
.label-pick.selected { border-color:var(--pick-color, #00e5ff); background:rgba(0,229,255,0.12); box-shadow:0 0 0 1px var(--pick-color, #00e5ff); }
|
||||
.label-pick-emoji { font-size:1.35rem; }
|
||||
|
||||
.docling-grid { display:grid; grid-template-columns:280px 1fr 1fr; gap:1rem; }
|
||||
@media (max-width:1100px) { .docling-grid { grid-template-columns:1fr; } }
|
||||
.docling-panel { border:1px solid rgba(0,229,255,0.12); border-radius:12px; padding:0.85rem; background:rgba(13,18,25,0.85); }
|
||||
.docling-file-list { max-height:420px; overflow:auto; }
|
||||
.docling-file-row {
|
||||
padding:0.45rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.75rem;
|
||||
border:1px solid transparent; margin-bottom:0.25rem;
|
||||
}
|
||||
.docling-file-row:hover { background:rgba(0,229,255,0.06); }
|
||||
.docling-file-row.selected { border-color:rgba(0,229,255,0.35); background:rgba(0,229,255,0.1); }
|
||||
.docling-opt-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.35rem 0.75rem; font-size:0.78rem; }
|
||||
.docling-export-tabs { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.65rem 0; }
|
||||
.docling-export-tab {
|
||||
padding:0.3rem 0.65rem; border-radius:999px; font-size:0.72rem; cursor:pointer;
|
||||
border:1px solid rgba(100,116,139,0.35); background:transparent; color:#94a3b8;
|
||||
}
|
||||
.docling-export-tab.active { border-color:#00e5ff; color:#e0f2fe; background:rgba(0,229,255,0.12); }
|
||||
.docling-preview {
|
||||
max-height:360px; overflow:auto; font-size:0.74rem; line-height:1.45;
|
||||
background:#060a10; border:1px solid rgba(0,229,255,0.1); border-radius:8px; padding:0.65rem;
|
||||
white-space:pre-wrap; word-break:break-word;
|
||||
}
|
||||
.docling-preview.html-preview { white-space:normal; }
|
||||
.docling-status { font-size:0.72rem; color:#94a3b8; margin-top:0.35rem; }
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Wereldexport — KPIs + layout lock */
|
||||
|
||||
.wereldexport-workspace.vtabs-layout {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
.wereldexport-workspace > .vtabs-nav {
|
||||
flex: 0 0 220px !important;
|
||||
width: 220px !important;
|
||||
max-width: 220px !important;
|
||||
}
|
||||
.wereldexport-workspace > .vtabs-content {
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.ei-neo-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.ei-neo-kpi {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 14, 20, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.ei-neo-ring {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ei-neo-ring-inner {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: #0a0e14;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
|
||||
.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
|
||||
|
||||
.ei-sync-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ei-sync-bar .btn { font-size: 0.72rem; }
|
||||
@@ -0,0 +1,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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/* Parse Intelligence Workbench */
|
||||
.pi-workbench {
|
||||
--pi-accent: #00e5ff;
|
||||
--pi-gold: #ffd700;
|
||||
--pi-purple: #a855f7;
|
||||
--pi-bg: rgba(6, 10, 18, 0.92);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.12);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pi-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.pi-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
background: linear-gradient(90deg, #f1f5f9, #00e5ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
|
||||
.pi-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.pi-kpi-cell {
|
||||
padding: 0.85rem 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.pi-kpi-cell:last-child { border-right: none; }
|
||||
.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
|
||||
.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
|
||||
.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
|
||||
|
||||
.pi-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 380px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.pi-body { grid-template-columns: 240px 1fr; }
|
||||
.pi-detail { display: none; }
|
||||
.pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
|
||||
}
|
||||
|
||||
.pi-filters {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
max-height: 720px;
|
||||
}
|
||||
|
||||
.pi-filters label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin: 0.85rem 0 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-filters label:first-child { margin-top: 0; }
|
||||
|
||||
.pi-search {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-search:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
|
||||
}
|
||||
|
||||
.pi-select, .pi-range {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pi-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
margin: 0.45rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-toggle-row input { accent-color: #00e5ff; }
|
||||
|
||||
.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
|
||||
|
||||
.pi-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-filter-chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pi-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pi-trends-bar {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(168, 85, 247, 0.04);
|
||||
}
|
||||
|
||||
.pi-trends-bar .lbl {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
|
||||
.pi-hype-chip {
|
||||
font-size: 0.76rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 85, 247, 0.35);
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #e9d5ff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
|
||||
.pi-hype-chip.active {
|
||||
border-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
color: #a5f3fc;
|
||||
box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
|
||||
}
|
||||
|
||||
.pi-hype-chip.cross {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
|
||||
|
||||
.pi-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.pi-toolbar .count {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pi-toolbar .count strong { color: #00e5ff; }
|
||||
|
||||
.pi-sort-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-sort-btn.active {
|
||||
border-color: rgba(0, 229, 255, 0.45);
|
||||
color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
|
||||
|
||||
.pi-view-toggle button {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
|
||||
|
||||
.pi-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pi-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(10, 15, 25, 0.98);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
color: #64748b;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pi-table th:hover { color: #00e5ff; }
|
||||
|
||||
.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
|
||||
|
||||
.pi-table td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
vertical-align: middle;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-table tr { cursor: pointer; transition: background 0.12s; }
|
||||
|
||||
.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.pi-table tbody tr.selected {
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
box-shadow: inset 3px 0 0 #00e5ff;
|
||||
}
|
||||
|
||||
.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
|
||||
.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
|
||||
.pi-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
|
||||
|
||||
.pi-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-y: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
|
||||
.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
|
||||
|
||||
.pi-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pi-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-detail-head {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
|
||||
|
||||
.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
|
||||
|
||||
.pi-detail-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pi-detail-tabs button.active {
|
||||
color: #00e5ff;
|
||||
border-bottom-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.05);
|
||||
}
|
||||
|
||||
.pi-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.pi-read-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pi-read-text mark {
|
||||
background: rgba(255, 215, 0, 0.35);
|
||||
color: #fef9c3;
|
||||
padding: 0 0.15rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pi-link-list { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.pi-link-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pi-link-list a { color: #86efac; word-break: break-all; }
|
||||
|
||||
.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.pi-heading-item {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
border-left: 3px solid rgba(0, 229, 255, 0.4);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
@@ -0,0 +1,493 @@
|
||||
/* Revenue Cockpit — Excel layout (licht) */
|
||||
.rc-excel-page {
|
||||
--rc-green: #92d050;
|
||||
--rc-red: #ff0000;
|
||||
--rc-orange: #ffc000;
|
||||
--rc-yellow: #ffff00;
|
||||
--rc-blue: #bdd7ee;
|
||||
--rc-border: #b4b4b4;
|
||||
--rc-text: #000;
|
||||
background: #e8e8e8;
|
||||
color: var(--rc-text);
|
||||
margin: -1rem -1.25rem 0;
|
||||
padding: 0.75rem 1rem 2rem;
|
||||
min-height: calc(100vh - 4rem);
|
||||
font-family: Calibri, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.persona-ceo .main-topnav:has(.rc-excel-page),
|
||||
.main-topnav:has(.rc-excel-page) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.rc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rc-toolbar-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.rc-save-hint { font-size: 0.72rem; color: #666; }
|
||||
.rc-saved { color: #217346; }
|
||||
.rc-btn {
|
||||
border: 1px solid #aaa;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
color: #222;
|
||||
}
|
||||
.rc-btn:hover { background: #f3f3f3; }
|
||||
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
|
||||
.rc-btn-import { font-size: 0.72rem; }
|
||||
|
||||
.rc-sheet-wrap {
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
|
||||
|
||||
.rc-sheet {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rc-col-logo { width: 72px; }
|
||||
.rc-col-deal { width: 16%; }
|
||||
.rc-col-num { width: 10%; }
|
||||
.rc-col-steps { width: auto; }
|
||||
|
||||
/* Alleen bewerk-modus (echte Excel) — niet lekken naar cockpit */
|
||||
.rc-sheet .rc-row td {
|
||||
border: 1px solid var(--rc-border);
|
||||
vertical-align: top;
|
||||
padding: 4px 6px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.rc-sheet .rc-row-goals td {
|
||||
background: var(--rc-yellow);
|
||||
font-weight: 600;
|
||||
min-height: 48px;
|
||||
}
|
||||
.rc-sheet .rc-cell-tagline {
|
||||
font-size: 14pt;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-sheet .rc-row-head td {
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #666;
|
||||
}
|
||||
.rc-sheet .rc-th { text-align: left; }
|
||||
.rc-sheet .rc-num { text-align: right; }
|
||||
|
||||
.rc-sheet .rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
|
||||
.rc-sheet .rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
|
||||
.rc-sheet .rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
|
||||
.rc-sheet .rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
|
||||
.rc-sheet .rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
|
||||
.rc-sheet .rc-style-white td:not(.rc-logo-cell) { background: #fff; }
|
||||
|
||||
.rc-sheet .rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
|
||||
.rc-sheet .rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
|
||||
|
||||
.rc-sheet .rc-cell-edit:focus {
|
||||
outline: 2px solid #217346;
|
||||
outline-offset: -2px;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.rc-sheet .rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.rc-sheet .rc-cell-deal { font-weight: 600; }
|
||||
.rc-sheet .rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
|
||||
|
||||
.rc-sheet .rc-logo-cell {
|
||||
background: #fff !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 6px 4px;
|
||||
width: 72px;
|
||||
}
|
||||
.rc-logo-cucina .rc-logo-ring {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 6.5pt;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
.rc-logo-foodlinkk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.rc-fl-brand {
|
||||
font-size: 11pt;
|
||||
font-weight: 900;
|
||||
color: #111;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rc-fl-brand::first-letter { color: #f5c400; }
|
||||
.rc-fl-sub {
|
||||
font-size: 5.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #333;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Rij panel */
|
||||
.rc-row-panel {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.rc-row-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
|
||||
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
|
||||
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
|
||||
.rc-inline select, .rc-inline input {
|
||||
flex: 1;
|
||||
border: 1px solid #bbb;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.rc-style-select {
|
||||
border: 1px solid #bbb;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
}
|
||||
|
||||
.rc-footnote {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
|
||||
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
|
||||
}
|
||||
|
||||
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
|
||||
.rc-page {
|
||||
--rc-green: #4ade80;
|
||||
--rc-red: #f87171;
|
||||
--rc-orange: #fb923c;
|
||||
--rc-yellow: #facc15;
|
||||
--rc-blue: #60a5fa;
|
||||
--rc-green-fill: #22c55e;
|
||||
--rc-red-fill: #ef4444;
|
||||
--rc-orange-fill: #f59e0b;
|
||||
--rc-yellow-fill: #eab308;
|
||||
--rc-blue-fill: #3b82f6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.rc-page.rc-mode-sheet { background: transparent; }
|
||||
|
||||
.rc-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.rc-header h1 { margin: 0; font-size: 1.25rem; }
|
||||
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
|
||||
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
|
||||
|
||||
.rc-view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid rgba(148,163,184,0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-toggle-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
|
||||
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
|
||||
|
||||
.rc-summary-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: rgba(15,20,25,0.6);
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.rc-sum-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
|
||||
.rc-sum-item strong { font-size: 1.05rem; color: #4ade80; font-variant-numeric: tabular-nums; }
|
||||
.rc-filter-inline {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148,163,184,0.18);
|
||||
background: #0a0e12;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
|
||||
.rc-dot-btn {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148,163,184,0.3);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
|
||||
.rc-dot-green { background: var(--rc-green); }
|
||||
.rc-dot-red { background: var(--rc-red); }
|
||||
.rc-dot-orange { background: var(--rc-orange); }
|
||||
.rc-dot-yellow { background: var(--rc-yellow); }
|
||||
.rc-dot-blue { background: var(--rc-blue); }
|
||||
.rc-dot-white { background: #e2e8f0; }
|
||||
|
||||
.rc-visual-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||
background: #0a0e12;
|
||||
}
|
||||
|
||||
.rc-visual-table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: #111820;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.rc-col-deal { width: 14%; }
|
||||
.rc-col-chart-num { width: 12%; }
|
||||
.rc-col-donut { width: 72px; }
|
||||
.rc-col-steps { width: auto; }
|
||||
|
||||
.rc-visual-goals td {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #fef3c7;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(234, 179, 8, 0.25);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-vtagline { font-style: italic; text-align: center; color: #fde047; font-size: 0.95rem; }
|
||||
|
||||
.rc-visual-head td {
|
||||
background: #1a2332;
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid rgba(148,163,184,0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rc-visual-row { cursor: pointer; }
|
||||
.rc-visual-row:hover td { background: #1e2a3a !important; }
|
||||
.rc-visual-row.rc-row-open td { background: #1a3050 !important; }
|
||||
|
||||
.rc-visual-row td,
|
||||
.rc-visual-goals td,
|
||||
.rc-visual-head td,
|
||||
.rc-vcell {
|
||||
border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
padding: 10px 12px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.45;
|
||||
background: #151c28;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Status = gekleurde linkerrand, neutrale donkere rij */
|
||||
.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
|
||||
.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
|
||||
.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
|
||||
.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
|
||||
.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
|
||||
.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
|
||||
|
||||
.rc-vdeal { font-weight: 700; }
|
||||
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rc-brand-tag {
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(148,163,184,0.15);
|
||||
}
|
||||
.rc-brand-tag-fl { color: #fbbf24; background: rgba(251,191,36,0.12); }
|
||||
|
||||
.rc-vnum { text-align: right; }
|
||||
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.92rem; font-weight: 700; color: #4ade80; }
|
||||
|
||||
.rc-mini-bar {
|
||||
height: 5px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-mini-bar-fill { height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.95; }
|
||||
.rc-mini-bar-year { background: #38bdf8 !important; }
|
||||
|
||||
.rc-vdonut { text-align: center; }
|
||||
.rc-donut {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rc-donut-label { font-size: 0.62rem; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
||||
.rc-donut-empty { color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
.rc-vsteps {
|
||||
font-size: 0.84rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 6px; }
|
||||
|
||||
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
|
||||
.rc-expand-cell { padding: 0 !important; }
|
||||
.rc-expand-inner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(10,14,18,0.98);
|
||||
color: #e2e8f0;
|
||||
border-top: 1px solid rgba(56,189,248,0.15);
|
||||
}
|
||||
.rc-expand-chart { flex: 0 0 140px; }
|
||||
.rc-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 72px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
|
||||
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
|
||||
.rc-bar-stack {
|
||||
width: 28px;
|
||||
min-height: 2px;
|
||||
background: var(--rc-green-fill);
|
||||
border-radius: 3px 3px 0 0;
|
||||
transition: height 0.2s;
|
||||
}
|
||||
.rc-bar-year { background: #38bdf8; }
|
||||
.rc-expand-stats { font-size: 0.75rem; }
|
||||
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.rc-expand-stats span { color: #64748b; }
|
||||
.rc-expand-stats strong { color: #34d399; }
|
||||
.rc-expand-body { flex: 1; min-width: 0; }
|
||||
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
|
||||
.rc-expand-body p { margin: 0; font-size: 0.9rem; line-height: 1.55; white-space: pre-wrap; color: #e2e8f0; }
|
||||
.rc-expand-task { margin-top: 0.65rem; }
|
||||
.rc-expand-task .rc-inline select,
|
||||
.rc-expand-task .rc-inline input {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #0f1419;
|
||||
color: #e2e8f0;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rc-page .rc-excel-page {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
background: #e8e8e8;
|
||||
color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-summary-strip { gap: 0.5rem; }
|
||||
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
|
||||
.rc-expand-inner { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
|
||||
<header class="rc-header">
|
||||
<div>
|
||||
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
|
||||
<p class="rc-subtitle" x-show="meta.file_mtime">
|
||||
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
|
||||
· <span x-text="fmtDate(meta.file_mtime)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rc-header-actions">
|
||||
<div class="rc-view-toggle">
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
|
||||
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Cockpit: Excel-layout met charts -->
|
||||
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
|
||||
|
||||
<div class="rc-summary-strip">
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / mnd</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / jr</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Deals</span>
|
||||
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
|
||||
</div>
|
||||
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
|
||||
<div class="rc-style-dots">
|
||||
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
|
||||
<template x-for="s in styleFilters" :key="'d-' + s.id">
|
||||
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-wrap">
|
||||
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-donut" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
|
||||
<!-- Excel rij 1: goals -->
|
||||
<tr class="rc-visual-goals">
|
||||
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
|
||||
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-visual-head">
|
||||
<td class="rc-vcell">Deal</td>
|
||||
<td class="rc-vcell rc-num">Marge mnd</td>
|
||||
<td class="rc-vcell rc-num">Marge jr</td>
|
||||
<td class="rc-vcell rc-num">Aandeel</td>
|
||||
<td class="rc-vcell">Next steps</td>
|
||||
</tr>
|
||||
|
||||
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
|
||||
<tr class="rc-visual-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
|
||||
<td class="rc-vcell rc-vdeal">
|
||||
<span class="rc-deal-name" x-text="row.name"></span>
|
||||
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
|
||||
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_month">
|
||||
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_year">
|
||||
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdonut">
|
||||
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
|
||||
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vsteps">
|
||||
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
|
||||
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
|
||||
<td class="rc-expand-cell" colspan="5">
|
||||
<div class="rc-expand-inner">
|
||||
<div class="rc-expand-chart">
|
||||
<div class="rc-bar-chart">
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-stats">
|
||||
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
|
||||
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
|
||||
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-body">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
|
||||
<div class="rc-expand-task" x-show="row.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerken: originele Excel -->
|
||||
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
|
||||
<div class="rc-toolbar">
|
||||
<span class="rc-toolbar-title">Bewerken · DB</span>
|
||||
<div class="rc-toolbar-actions">
|
||||
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
|
||||
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
|
||||
<table class="rc-sheet" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-logo" />
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
<tr class="rc-row rc-row-goals">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.vision_text || ''"
|
||||
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.horizon_text || ''"
|
||||
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.mid_text || ''"
|
||||
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
|
||||
x-init="$el.textContent = goals.tagline || ''"
|
||||
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
|
||||
</tr>
|
||||
<tr class="rc-row rc-row-head">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-th">Deal</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Month</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Year</td>
|
||||
<td class="rc-cell rc-th">Next steps</td>
|
||||
</tr>
|
||||
<template x-for="row in sheetRows" :key="row.id || row.source_row">
|
||||
<tr class="rc-row rc-data-row"
|
||||
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
|
||||
@click="selectRow(row)">
|
||||
<template x-if="row.logoCell === 'cucina'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="row.logoCell === 'foodlinkk'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-foodlinkk">
|
||||
<span class="rc-fl-brand">Foodlinkk</span>
|
||||
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.name || ''"
|
||||
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_month)"
|
||||
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_year)"
|
||||
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.next_steps || ''"
|
||||
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
|
||||
<div class="rc-row-panel-head">
|
||||
<strong x-text="selectedRow?.name"></strong>
|
||||
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
|
||||
<option value="green">Groen (live)</option>
|
||||
<option value="red">Rood (inactief)</option>
|
||||
<option value="orange">Oranje</option>
|
||||
<option value="yellow">Geel (sectie)</option>
|
||||
<option value="blue">Blauw</option>
|
||||
<option value="white">Wit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rc-row-panel-body">
|
||||
<label>Agent taak
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
|
||||
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=5"></script>
|
||||
{% endblock %}
|
||||
@@ -194,3 +194,16 @@
|
||||
.nav-pill.active { animation: none; }
|
||||
.nav-pill:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* Sidebar: app-sidebar-neo.css only — no topnav glow/pulse */
|
||||
.app-sidebar .nav-pill,
|
||||
.app-sidebar .nav-pill:hover,
|
||||
.app-sidebar .nav-pill.active {
|
||||
animation: none !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
.app-sidebar .nav-pill::before,
|
||||
.app-sidebar .nav-pill::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,117 +1,278 @@
|
||||
/* Vertical tab navigation with animated active indicator */
|
||||
/* Vertical tabs — clean module nav + page tabs */
|
||||
|
||||
.vtabs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
min-height: 60vh;
|
||||
width: 100%;
|
||||
min-height: 40vh;
|
||||
--vt-color: #0ea5e9;
|
||||
--vt-panel-bg: rgba(10, 14, 20, 0.85);
|
||||
}
|
||||
|
||||
.vtabs-layout > .vtabs-nav {
|
||||
flex: 0 0 220px;
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.vtabs-layout > .vtabs-content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.vtabs-layout { flex-direction: column !important; }
|
||||
.vtabs-layout > .vtabs-nav { flex: 1 1 auto; width: 100%; max-width: none; }
|
||||
}
|
||||
|
||||
.vtabs-nav {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 14, 20, 0.85);
|
||||
gap: 0.1rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtabs-nav::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: var(--vt-indicator-top, 0);
|
||||
width: 3px;
|
||||
height: var(--vt-indicator-h, 40px);
|
||||
background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
|
||||
border-radius: 0 4px 4px 0;
|
||||
transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
|
||||
box-shadow: 0 0 12px var(--vt-color, #00e5ff);
|
||||
height: var(--vt-indicator-h, 36px);
|
||||
background: var(--vt-color);
|
||||
border-radius: 0 3px 3px 0;
|
||||
transition: top 0.25s ease, height 0.2s ease;
|
||||
}
|
||||
|
||||
.vtabs-nav-header {
|
||||
padding: 0 0.35rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.vtabs-nav-header strong {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.vtab-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
.voice-live-page { --vl-accent: #a78bfa; max-width: 1400px; }
|
||||
|
||||
.voice-live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 320px;
|
||||
gap: 1rem;
|
||||
min-height: 520px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.voice-live-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.voice-panel {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.voice-mic-zone { align-items: center; text-align: center; }
|
||||
.voice-mic-btn {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(167, 139, 250, 0.45);
|
||||
background: radial-gradient(circle at 30% 30%, rgba(167, 139, 250, 0.25), rgba(15, 20, 25, 0.95));
|
||||
color: #f8fafc;
|
||||
font-size: 2.5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
margin: 0.5rem auto 1rem;
|
||||
}
|
||||
.voice-mic-btn:hover { border-color: rgba(167, 139, 250, 0.8); transform: scale(1.03); }
|
||||
.voice-mic-btn.is-listening {
|
||||
border-color: #f87171;
|
||||
box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.2);
|
||||
animation: vl-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.voice-mic-btn.is-busy {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
.voice-mic-btn.is-handsfree {
|
||||
border-color: #34d399;
|
||||
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
|
||||
animation: vl-pulse-hf 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes vl-pulse-hf {
|
||||
0%, 100% { box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(52, 211, 153, 0.22); }
|
||||
}
|
||||
@keyframes vl-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0.15); }
|
||||
50% { box-shadow: 0 0 0 14px rgba(248, 113, 113, 0.28); }
|
||||
}
|
||||
|
||||
.voice-status {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.voice-status-sub { font-size: 0.72rem; color: #64748b; margin-bottom: 0.75rem; }
|
||||
|
||||
.voice-live-preview {
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 20, 25, 0.85);
|
||||
border: 1px dashed rgba(167, 139, 250, 0.35);
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.voice-live-preview.is-interim { color: #94a3b8; font-style: italic; }
|
||||
.voice-live-preview strong { color: #a78bfa; font-size: 0.68rem; text-transform: uppercase; display: block; margin-bottom: 0.25rem; }
|
||||
|
||||
.voice-toggles { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 1rem; text-align: left; }
|
||||
.voice-toggles label { font-size: 0.78rem; color: #94a3b8; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
|
||||
|
||||
.voice-chat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.voice-chat-head h2 { margin: 0; font-size: 0.95rem; color: #f8fafc; }
|
||||
.voice-chat-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.voice-bubble {
|
||||
max-width: 92%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.voice-bubble-user {
|
||||
align-self: flex-end;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
.voice-bubble-agent {
|
||||
align-self: flex-start;
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border: 1px solid rgba(167, 139, 250, 0.28);
|
||||
}
|
||||
.voice-bubble-meta { font-size: 0.68rem; color: #64748b; margin-bottom: 0.25rem; }
|
||||
.voice-bubble-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(167, 139, 250, 0.2);
|
||||
color: #c4b5fd;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.voice-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.voice-side-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin: 0 0 0.5rem; }
|
||||
.voice-pipeline { list-style: none; margin: 0 0 1rem; padding: 0; max-height: 220px; overflow-y: auto; }
|
||||
.voice-pipeline li {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.voice-pipeline .vl-phase {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.voice-pipeline .vl-label { color: #e2e8f0; font-weight: 600; }
|
||||
.voice-pipeline .vl-detail { grid-column: 2; color: #94a3b8; font-size: 0.72rem; }
|
||||
.voice-pipeline li.is-running .vl-label { color: #38bdf8; }
|
||||
.voice-pipeline li.is-error .vl-label { color: #f87171; }
|
||||
|
||||
.voice-feed { max-height: 200px; overflow-y: auto; font-size: 0.72rem; }
|
||||
.voice-feed-item { padding: 0.35rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); color: #94a3b8; }
|
||||
.voice-feed-item strong { color: #c4b5fd; }
|
||||
.voice-feed-item .vl-ts { color: #64748b; margin-right: 0.35rem; }
|
||||
|
||||
.voice-manual-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.voice-manual-row .form-input { flex: 1; font-size: 0.82rem; }
|
||||
|
||||
.voice-confirm-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.voice-confirm-text { font-size: 0.82rem; color: #fde68a; }
|
||||
.voice-confirm-text strong { display: block; color: #fbbf24; font-size: 0.72rem; text-transform: uppercase; margin-bottom: 0.2rem; }
|
||||
.voice-confirm-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.voice-results-modal { z-index: 1400; }
|
||||
.voice-results-card { width: min(720px, 96vw); max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.voice-results-table-wrap { overflow: auto; max-height: 50vh; margin: 0.5rem 0 1rem; border: 1px solid rgba(148,163,184,0.12); border-radius: 8px; }
|
||||
.voice-results-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
.voice-results-table th { text-align: left; padding: 0.5rem 0.65rem; background: rgba(15,20,25,0.9); color: #64748b; font-size: 0.65rem; text-transform: uppercase; position: sticky; top: 0; }
|
||||
.voice-results-table td { padding: 0.45rem 0.65rem; border-top: 1px solid rgba(148,163,184,0.08); color: #cbd5e1; }
|
||||
.voice-results-table a { color: #38bdf8; text-decoration: none; }
|
||||
.voice-results-table a:hover { text-decoration: underline; }
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overview",
|
||||
"revenue_cockpit": "Revenue Cockpit",
|
||||
"cto_home": "CTO Overview",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Clients",
|
||||
"deals": "Deals",
|
||||
@@ -55,7 +65,8 @@
|
||||
"new": "New client",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "List",
|
||||
"tab_stores": "Supermarkets"
|
||||
"tab_stores": "Supermarkets",
|
||||
"tab_360": "360° Client"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product catalog",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cucina · Foodlinkk Command Center",
|
||||
"short_name": "Foodlinkk",
|
||||
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e14",
|
||||
"theme_color": "#0a0e14",
|
||||
"lang": "nl",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overzicht",
|
||||
"revenue_cockpit": "Revenue Cockpit",
|
||||
"cto_home": "CTO Overzicht",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Klanten",
|
||||
"deals": "Deals",
|
||||
@@ -55,7 +65,8 @@
|
||||
"new": "Nieuwe klant",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "Lijst",
|
||||
"tab_stores": "Supermarkten"
|
||||
"tab_stores": "Supermarkten",
|
||||
"tab_360": "360° Klant"
|
||||
},
|
||||
"products": {
|
||||
"title": "Productcatalogus",
|
||||
|
||||
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
|
||||
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
|
||||
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
|
||||
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
|
||||
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
|
||||
};
|
||||
|
||||
function cfg(key, fallbackColor) {
|
||||
|
||||
@@ -1,9 +1,149 @@
|
||||
(function () {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const TIER_ROWS = [4, 5, 7];
|
||||
const MIN_NODE_GAP = 148;
|
||||
const VIEW_W = 1200;
|
||||
const VIEW_H = 860;
|
||||
|
||||
function make(tag, attrs = {}) {
|
||||
/** Korte rol-context per agent (NL) */
|
||||
const AGENT_HINTS = {
|
||||
marketing: { short: 'Campagnes', domain: 'Groei' },
|
||||
retail: { short: 'Supermarkten', domain: 'Groei' },
|
||||
bizdev: { short: 'Deals', domain: 'Groei' },
|
||||
research: { short: 'Marktintel', domain: 'Groei' },
|
||||
browser: { short: 'Web research', domain: 'Groei' },
|
||||
webbuilder: { short: 'Websites', domain: 'Product' },
|
||||
product: { short: 'Assortiment', domain: 'Product' },
|
||||
packaging: { short: 'Verpakking', domain: 'Product' },
|
||||
design: { short: 'Visuals', domain: 'Product' },
|
||||
halal: { short: 'Compliance', domain: 'Product' },
|
||||
sourcing: { short: 'Inkoop', domain: 'Product' },
|
||||
finance: { short: 'Cijfers', domain: 'Operatie' },
|
||||
hr: { short: 'Team', domain: 'Operatie' },
|
||||
knowledge: { short: 'Kennisbank', domain: 'Operatie' },
|
||||
email: { short: 'Inbox', domain: 'Operatie' },
|
||||
sysops: { short: 'Infra', domain: 'Operatie' },
|
||||
};
|
||||
|
||||
const CLUSTERS = {
|
||||
growth: {
|
||||
label: 'Groei',
|
||||
icon: '📈',
|
||||
color: 'rgba(0, 229, 255, 0.14)',
|
||||
stroke: 'rgba(0, 229, 255, 0.35)',
|
||||
agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
|
||||
},
|
||||
product: {
|
||||
label: 'Product',
|
||||
icon: '📦',
|
||||
color: 'rgba(168, 85, 247, 0.12)',
|
||||
stroke: 'rgba(168, 85, 247, 0.35)',
|
||||
agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
|
||||
},
|
||||
ops: {
|
||||
label: 'Operatie',
|
||||
icon: '⚙️',
|
||||
color: 'rgba(34, 197, 94, 0.1)',
|
||||
stroke: 'rgba(34, 197, 94, 0.32)',
|
||||
agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {Record<string, object>} */
|
||||
const COMM_MODES = {
|
||||
pyramid: {
|
||||
id: 'pyramid',
|
||||
label: 'Piramide',
|
||||
subtitle: 'Herman als hub',
|
||||
icon: '◆',
|
||||
desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: false,
|
||||
activeOnly: false,
|
||||
edgeBend: 0.42,
|
||||
},
|
||||
mesh: {
|
||||
id: 'mesh',
|
||||
label: 'Vol netwerk',
|
||||
subtitle: 'Alle partners',
|
||||
icon: '⬡',
|
||||
desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: true,
|
||||
activeOnly: false,
|
||||
staticPeerOpacity: 0.35,
|
||||
edgeBend: 0.38,
|
||||
},
|
||||
org: {
|
||||
id: 'org',
|
||||
label: 'Organigram',
|
||||
subtitle: 'Pure hiërarchie',
|
||||
icon: '▤',
|
||||
desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: false,
|
||||
showStaticPeers: false,
|
||||
activeOnly: false,
|
||||
edgeBend: 0.12,
|
||||
straightExec: true,
|
||||
},
|
||||
pulse: {
|
||||
id: 'pulse',
|
||||
label: 'Live stream',
|
||||
subtitle: 'Alleen actief',
|
||||
icon: '◎',
|
||||
desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: false,
|
||||
activeOnly: true,
|
||||
edgeBend: 0.45,
|
||||
},
|
||||
clusters: {
|
||||
id: 'clusters',
|
||||
label: 'Domeinen',
|
||||
subtitle: 'Per afdeling',
|
||||
icon: '◉',
|
||||
desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
|
||||
layout: 'clusters',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: true,
|
||||
activeOnly: false,
|
||||
staticPeerOpacity: 0.2,
|
||||
edgeBend: 0.5,
|
||||
intraClusterOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
|
||||
let cachedData = null;
|
||||
let pickerBound = false;
|
||||
let onModeChangeCb = null;
|
||||
let lastMountOpts = {};
|
||||
|
||||
function make(tag, attrs) {
|
||||
attrs = attrs || {};
|
||||
const el = document.createElementNS(NS, tag);
|
||||
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
|
||||
Object.entries(attrs).forEach(function (pair) {
|
||||
el.setAttribute(pair[0], String(pair[1]));
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -11,9 +151,234 @@
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function linePath(x1, y1, x2, y2) {
|
||||
const midY = y1 + (y2 - y1) * 0.45;
|
||||
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
function curvePath(x1, y1, x2, y2, bend) {
|
||||
if (bend != null && bend < 0.18) {
|
||||
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
|
||||
}
|
||||
const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
|
||||
return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
|
||||
}
|
||||
|
||||
function clusterOf(key) {
|
||||
const k = (key || '').toLowerCase();
|
||||
return (
|
||||
Object.keys(CLUSTERS).find(function (cid) {
|
||||
return CLUSTERS[cid].agents.indexOf(k) >= 0;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function spreadTier(nodes, width, minGap) {
|
||||
if (nodes.length <= 1) return;
|
||||
nodes.sort(function (a, b) {
|
||||
return a._x - b._x;
|
||||
});
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
const gap = nodes[i]._x - nodes[i - 1]._x;
|
||||
if (gap < minGap) {
|
||||
const shift = minGap - gap;
|
||||
for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
|
||||
}
|
||||
}
|
||||
const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
|
||||
const offset = width / 2 - tierCx;
|
||||
nodes.forEach(function (n) {
|
||||
n._x += offset;
|
||||
});
|
||||
}
|
||||
|
||||
function deoverlapLayout(souls, width) {
|
||||
const tiers = {};
|
||||
souls.forEach(function (n) {
|
||||
const y = n._y || 0;
|
||||
if (!tiers[y]) tiers[y] = [];
|
||||
tiers[y].push(n);
|
||||
});
|
||||
Object.keys(tiers).forEach(function (y) {
|
||||
spreadTier(tiers[y], width, MIN_NODE_GAP);
|
||||
});
|
||||
}
|
||||
|
||||
function agentSubtitle(node) {
|
||||
const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
|
||||
const task = (node.current_task || node.last_event_title || '').trim();
|
||||
if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
|
||||
if (hint) return hint.short;
|
||||
return (node.role_title || '').split('·')[0].trim().slice(0, 24);
|
||||
}
|
||||
|
||||
function agentStatus(node) {
|
||||
if (node.is_active) return 'live';
|
||||
const h = node.health || 'idle';
|
||||
if (h === 'warn') return 'warn';
|
||||
if (h === 'healthy') return 'ready';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function addRotateAnim(group, dur) {
|
||||
const anim = document.createElementNS(NS, 'animateTransform');
|
||||
anim.setAttribute('attributeName', 'transform');
|
||||
anim.setAttribute('type', 'rotate');
|
||||
anim.setAttribute('from', '0 0 0');
|
||||
anim.setAttribute('to', '360 0 0');
|
||||
anim.setAttribute('dur', dur || '12s');
|
||||
anim.setAttribute('repeatCount', 'indefinite');
|
||||
group.appendChild(anim);
|
||||
}
|
||||
|
||||
function layoutPyramid(souls, width, positions) {
|
||||
const cx = width / 2;
|
||||
const hermanY = positions.hermanY;
|
||||
const execY = positions.execY;
|
||||
const tierYs = positions.tierYs;
|
||||
const minGap = MIN_NODE_GAP;
|
||||
let idx = 0;
|
||||
TIER_ROWS.forEach(function (count, tierIdx) {
|
||||
const y = tierYs[tierIdx] || 580;
|
||||
const slice = souls.slice(idx, idx + count);
|
||||
idx += count;
|
||||
const n = slice.length;
|
||||
if (!n) return;
|
||||
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
|
||||
const startX = cx - span / 2;
|
||||
slice.forEach(function (node, i) {
|
||||
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
|
||||
node._y = y;
|
||||
node._tier = tierIdx;
|
||||
node._cluster = clusterOf(node.agent_key);
|
||||
});
|
||||
});
|
||||
while (idx < souls.length) {
|
||||
const y = 660;
|
||||
const rest = souls.slice(idx);
|
||||
const n = rest.length;
|
||||
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
|
||||
const startX = cx - span / 2;
|
||||
rest.forEach(function (node, i) {
|
||||
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
|
||||
node._y = y;
|
||||
node._cluster = clusterOf(node.agent_key);
|
||||
});
|
||||
idx = souls.length;
|
||||
}
|
||||
deoverlapLayout(souls, width);
|
||||
return { cx: cx, hermanY: hermanY, execY: execY };
|
||||
}
|
||||
|
||||
function layoutClusters(souls, width, height) {
|
||||
const cx = width / 2;
|
||||
const hermanY = height * 0.42;
|
||||
const execY = 58;
|
||||
const clusterCenters = {
|
||||
growth: { x: cx - 280, y: height * 0.72, r: 118 },
|
||||
product: { x: cx, y: height * 0.78, r: 118 },
|
||||
ops: { x: cx + 280, y: height * 0.72, r: 118 },
|
||||
};
|
||||
|
||||
souls.forEach(function (node) {
|
||||
const key = (node.agent_key || '').toLowerCase();
|
||||
const cid = clusterOf(key) || 'product';
|
||||
const center = clusterCenters[cid];
|
||||
const members = CLUSTERS[cid].agents;
|
||||
const idx = members.indexOf(key);
|
||||
const n = members.length;
|
||||
const angleStart = -Math.PI * 0.72;
|
||||
const angleEnd = -Math.PI * 0.28;
|
||||
const angle =
|
||||
n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
|
||||
const dist = center.r * 0.72;
|
||||
node._x = center.x + Math.cos(angle) * dist;
|
||||
node._y = center.y + Math.sin(angle) * dist * 0.55;
|
||||
node._cluster = cid;
|
||||
node._clusterCx = center.x;
|
||||
node._clusterCy = center.y;
|
||||
});
|
||||
|
||||
return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
|
||||
}
|
||||
|
||||
function isSvgVisible(svg) {
|
||||
if (!svg) return false;
|
||||
const r = svg.getBoundingClientRect();
|
||||
return r.width > 8 && r.height > 8;
|
||||
}
|
||||
|
||||
function showMeshLoading(svg, text) {
|
||||
clear(svg);
|
||||
const g = make('g', { class: 'mesh-loading' });
|
||||
const t = make('text', {
|
||||
x: 600,
|
||||
y: 360,
|
||||
'text-anchor': 'middle',
|
||||
fill: '#94a3b8',
|
||||
'font-size': 14,
|
||||
});
|
||||
t.textContent = text || 'Netwerk laden…';
|
||||
g.appendChild(t);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
function showMeshError(svg, text) {
|
||||
clear(svg);
|
||||
const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
|
||||
t.textContent = text || 'Mesh kon niet geladen worden';
|
||||
svg.appendChild(t);
|
||||
}
|
||||
|
||||
function whenSvgVisible(svg, fn) {
|
||||
if (!svg) return;
|
||||
if (isSvgVisible(svg)) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
let tries = 0;
|
||||
const tick = function () {
|
||||
tries += 1;
|
||||
if (isSvgVisible(svg)) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
if (tries < 60) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function watchSvgResize(svg, renderFn) {
|
||||
if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
|
||||
const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
|
||||
if (!wrap) return;
|
||||
let hadSize = isSvgVisible(svg);
|
||||
svg._meshResizeObs = new ResizeObserver(function () {
|
||||
const vis = isSvgVisible(svg);
|
||||
if (vis && !hadSize) {
|
||||
hadSize = true;
|
||||
renderFn();
|
||||
} else if (vis) {
|
||||
hadSize = true;
|
||||
}
|
||||
});
|
||||
svg._meshResizeObs.observe(wrap);
|
||||
}
|
||||
|
||||
function renderMesh(svg, opts) {
|
||||
if (!svg || !cachedData) return;
|
||||
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
|
||||
commMode: (opts && opts.commMode) || getSavedMode(),
|
||||
});
|
||||
const doDraw = function () {
|
||||
try {
|
||||
drawMesh(svg, cachedData, drawOpts);
|
||||
} catch (e) {
|
||||
console.error('drawMesh failed', e);
|
||||
showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
|
||||
}
|
||||
};
|
||||
doDraw();
|
||||
watchSvgResize(svg, doDraw);
|
||||
}
|
||||
|
||||
async function loadMeshData() {
|
||||
@@ -22,67 +387,209 @@
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function drawMesh(svg, data) {
|
||||
async function ensureMeshData() {
|
||||
if (cachedData) return cachedData;
|
||||
cachedData = await loadMeshData();
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
let pendingRender = false;
|
||||
|
||||
function scheduleRender(opts) {
|
||||
const svg = document.getElementById('agents-mesh-canvas');
|
||||
if (!svg) return;
|
||||
if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
|
||||
if (pendingRender) return;
|
||||
pendingRender = true;
|
||||
whenSvgVisible(svg, function () {
|
||||
pendingRender = false;
|
||||
renderMesh(svg, opts);
|
||||
});
|
||||
}
|
||||
|
||||
function nodePos(nodesByKey, key) {
|
||||
const n = nodesByKey[key];
|
||||
if (n && n._x != null) return { x: n._x, y: n._y };
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMode(opts) {
|
||||
const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
|
||||
return COMM_MODES[id] || COMM_MODES.pyramid;
|
||||
}
|
||||
|
||||
function drawMesh(svg, data, opts) {
|
||||
opts = opts || {};
|
||||
const mode = resolveMode(opts);
|
||||
const onNodeClick = opts.onNodeClick;
|
||||
|
||||
clear(svg);
|
||||
svg.setAttribute('data-mesh-mode', mode.id);
|
||||
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const width = vb && vb.width ? vb.width : 1100;
|
||||
const height = vb && vb.height ? vb.height : 640;
|
||||
const width = vb && vb.width ? vb.width : VIEW_W;
|
||||
const height = vb && vb.height ? vb.height : VIEW_H;
|
||||
if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
|
||||
svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
|
||||
}
|
||||
|
||||
const cx = width / 2;
|
||||
const hermanY = 280;
|
||||
const execY = 75;
|
||||
const agentY = 520;
|
||||
const positions = {
|
||||
execY: 72,
|
||||
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
|
||||
tierYs: [400, 540, 690],
|
||||
};
|
||||
|
||||
const souls = (data.nodes || []).filter((n) => {
|
||||
const souls = (data.nodes || []).filter(function (n) {
|
||||
const key = (n.agent_key || '').toLowerCase();
|
||||
return key && key !== 'herman';
|
||||
});
|
||||
|
||||
const edgesBySource = {};
|
||||
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
|
||||
const layoutMeta =
|
||||
mode.layout === 'clusters'
|
||||
? layoutClusters(souls, width, height)
|
||||
: layoutPyramid(souls, width, positions);
|
||||
|
||||
const agentCount = Math.max(1, souls.length);
|
||||
const pad = 70;
|
||||
const span = width - pad * 2;
|
||||
souls.forEach((node, i) => {
|
||||
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
|
||||
if (agentCount === 1) node._x = cx;
|
||||
node._y = agentY;
|
||||
const cx = layoutMeta.cx;
|
||||
const hermanY = layoutMeta.hermanY;
|
||||
const execY = layoutMeta.execY;
|
||||
|
||||
const nodesByKey = {};
|
||||
souls.forEach(function (n) {
|
||||
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
|
||||
});
|
||||
|
||||
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
|
||||
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
|
||||
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
|
||||
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
|
||||
|
||||
const edgesLayer = make('g');
|
||||
const nodesLayer = make('g');
|
||||
const bgLayer = make('g', { class: 'mesh-bg' });
|
||||
const clusterLayer = make('g', { class: 'mesh-clusters' });
|
||||
const edgesLayer = make('g', { class: 'mesh-edges' });
|
||||
const nodesLayer = make('g', { class: 'mesh-nodes' });
|
||||
svg.appendChild(bgLayer);
|
||||
if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
|
||||
svg.appendChild(edgesLayer);
|
||||
svg.appendChild(nodesLayer);
|
||||
|
||||
function addEdge(x1, y1, x2, y2, pulse) {
|
||||
const d = linePath(x1, y1, x2, y2);
|
||||
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
|
||||
if (mode.layout === 'pyramid') {
|
||||
positions.tierYs.forEach(function (y, i) {
|
||||
bgLayer.appendChild(
|
||||
make('rect', {
|
||||
x: 40,
|
||||
y: y - 42,
|
||||
width: width - 80,
|
||||
height: 84,
|
||||
rx: 12,
|
||||
class: 'mesh-tier-band mesh-tier-' + i,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
|
||||
Object.keys(CLUSTERS).forEach(function (cid) {
|
||||
const c = CLUSTERS[cid];
|
||||
const center = layoutMeta.clusterCenters[cid];
|
||||
if (!center) return;
|
||||
const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
|
||||
g.appendChild(
|
||||
make('ellipse', {
|
||||
cx: center.x,
|
||||
cy: center.y,
|
||||
rx: center.r + 28,
|
||||
ry: center.r * 0.62,
|
||||
fill: c.color,
|
||||
stroke: c.stroke,
|
||||
'stroke-width': 1.2,
|
||||
})
|
||||
);
|
||||
const lbl = make('text', {
|
||||
x: center.x,
|
||||
y: center.y - center.r * 0.35,
|
||||
class: 'mesh-cluster-label',
|
||||
'text-anchor': 'middle',
|
||||
});
|
||||
lbl.textContent = c.icon + ' ' + c.label;
|
||||
g.appendChild(lbl);
|
||||
clusterLayer.appendChild(g);
|
||||
});
|
||||
}
|
||||
|
||||
function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
|
||||
const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
|
||||
const attrs = { d: d, class: 'mesh-edge ' + cls };
|
||||
if (extraAttrs) Object.assign(attrs, extraAttrs);
|
||||
edgesLayer.appendChild(make('path', attrs));
|
||||
if (pulse) {
|
||||
const p = make('path', { d, class: 'mesh-pulse' });
|
||||
p.style.animationDuration = pulse + 's';
|
||||
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
|
||||
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
|
||||
edgesLayer.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
souls.forEach((node) => {
|
||||
const weight = edgesBySource[node.agent_key]?.weight || 1;
|
||||
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
|
||||
if (mode.showExecutive) {
|
||||
const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
|
||||
addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
|
||||
addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
|
||||
}
|
||||
|
||||
(data.peer_edges || []).forEach(function (e) {
|
||||
const live = e.type === 'live';
|
||||
if (live && !mode.showLivePeers) return;
|
||||
if (!live && !mode.showStaticPeers) return;
|
||||
if (mode.activeOnly && !live) return;
|
||||
|
||||
const a = nodePos(nodesByKey, e.source);
|
||||
const b = nodePos(nodesByKey, e.target);
|
||||
if (!a || !b) return;
|
||||
|
||||
const srcNode = nodesByKey[e.source];
|
||||
const tgtNode = nodesByKey[e.target];
|
||||
const sameCluster =
|
||||
srcNode &&
|
||||
tgtNode &&
|
||||
srcNode._cluster &&
|
||||
srcNode._cluster === tgtNode._cluster;
|
||||
|
||||
if (mode.intraClusterOnly && !sameCluster) return;
|
||||
|
||||
const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
|
||||
const pulse = live ? 2.2 : false;
|
||||
const extra = {};
|
||||
if (!live && mode.staticPeerOpacity != null) {
|
||||
extra['stroke-opacity'] = mode.staticPeerOpacity;
|
||||
}
|
||||
if (sameCluster && mode.layout === 'clusters') {
|
||||
extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
|
||||
}
|
||||
|
||||
addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
|
||||
});
|
||||
|
||||
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
|
||||
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
|
||||
if (mode.showReport) {
|
||||
(data.report_edges || []).forEach(function (e) {
|
||||
if (mode.activeOnly && !e.active) return;
|
||||
const a = nodePos(nodesByKey, e.source);
|
||||
if (!a) return;
|
||||
addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode.showDelegate) {
|
||||
(data.delegate_edges || []).forEach(function (e) {
|
||||
if (mode.activeOnly && !e.active) return;
|
||||
const b = nodePos(nodesByKey, e.target);
|
||||
if (!b) return;
|
||||
addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
|
||||
});
|
||||
}
|
||||
|
||||
function drawExec(node, cls) {
|
||||
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
|
||||
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
|
||||
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
|
||||
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
|
||||
g.lastChild.textContent = node.label;
|
||||
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
|
||||
const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
|
||||
g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
|
||||
const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
|
||||
t.textContent = node.label;
|
||||
g.appendChild(t);
|
||||
const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
|
||||
sub.textContent = node.sub;
|
||||
g.appendChild(sub);
|
||||
nodesLayer.appendChild(g);
|
||||
@@ -91,56 +598,222 @@
|
||||
drawExec(ceo, 'mesh-ceo');
|
||||
drawExec(cto, 'mesh-cto');
|
||||
|
||||
const herman = make('g', { class: 'mesh-node mesh-herman' });
|
||||
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
|
||||
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
|
||||
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
|
||||
crown.textContent = '👑';
|
||||
herman.appendChild(crown);
|
||||
const hLabel = make('text', { x: cx, y: hermanY + 18 });
|
||||
hLabel.textContent = 'Herman · Co-CEO';
|
||||
herman.appendChild(hLabel);
|
||||
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
|
||||
hSub.textContent = 'Takenverdeler';
|
||||
herman.appendChild(hSub);
|
||||
nodesLayer.appendChild(herman);
|
||||
const hermanActive =
|
||||
(data.delegate_edges || []).some(function (e) {
|
||||
return e.active;
|
||||
}) ||
|
||||
(data.report_edges || []).some(function (e) {
|
||||
return e.active;
|
||||
});
|
||||
|
||||
souls.forEach((node) => {
|
||||
const 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;
|
||||
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 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 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 ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
|
||||
ctx.textContent = agentSubtitle(node);
|
||||
group.appendChild(ctx);
|
||||
if (hint && mode.layout !== 'clusters') {
|
||||
const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
|
||||
dom.textContent = hint.domain;
|
||||
group.appendChild(dom);
|
||||
}
|
||||
const tip =
|
||||
(node.role_title || '') +
|
||||
(node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
|
||||
(node.current_task ? '\nNu: ' + node.current_task : '');
|
||||
group.setAttribute('title', tip.slice(0, 200));
|
||||
group.addEventListener('click', function () {
|
||||
if (onNodeClick) onNodeClick(node);
|
||||
});
|
||||
nodesLayer.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(targetId) {
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg) return;
|
||||
try {
|
||||
const data = await loadMeshData();
|
||||
drawMesh(svg, data);
|
||||
} catch (e) {
|
||||
clear(svg);
|
||||
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
|
||||
msg.textContent = 'Mesh kon niet geladen worden';
|
||||
svg.appendChild(msg);
|
||||
function updatePickerUI(modeId) {
|
||||
document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
|
||||
const active = btn.getAttribute('data-mesh-mode') === modeId;
|
||||
btn.classList.toggle('is-active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
const wrap = document.querySelector('.mesh-wrap');
|
||||
if (wrap) {
|
||||
wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
|
||||
wrap.setAttribute('data-mode', modeId);
|
||||
}
|
||||
const badge = document.getElementById('mesh-mode-badge');
|
||||
if (badge && COMM_MODES[modeId]) {
|
||||
badge.textContent = COMM_MODES[modeId].label;
|
||||
}
|
||||
}
|
||||
|
||||
window.AgentsMesh = { mount };
|
||||
function pickMode(modeId, opts, silent) {
|
||||
const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
|
||||
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
|
||||
if (!silent) saveMode(mode);
|
||||
updatePickerUI(mode);
|
||||
if (onModeChangeCb) onModeChangeCb(mode);
|
||||
const svg = document.getElementById('agents-mesh-canvas');
|
||||
if (!svg) return mode;
|
||||
showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
|
||||
ensureMeshData()
|
||||
.then(function () {
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, drawOpts);
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
|
||||
});
|
||||
return mode;
|
||||
}
|
||||
|
||||
function bindModePicker(opts) {
|
||||
lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
|
||||
if (pickerBound) return;
|
||||
pickerBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-mesh-mode]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = btn.getAttribute('data-mesh-mode');
|
||||
pickMode(id);
|
||||
});
|
||||
}
|
||||
|
||||
function setModeChangeCallback(fn) {
|
||||
onModeChangeCb = fn;
|
||||
}
|
||||
|
||||
async function mount(targetId, opts) {
|
||||
opts = opts || {};
|
||||
lastMountOpts = opts;
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg) return null;
|
||||
bindModePicker(opts);
|
||||
const modeObj = resolveMode(opts);
|
||||
updatePickerUI(modeObj.id);
|
||||
showMeshLoading(svg, 'Netwerk laden…');
|
||||
try {
|
||||
await ensureMeshData();
|
||||
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, drawOpts);
|
||||
});
|
||||
return cachedData;
|
||||
} catch (e) {
|
||||
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(targetId, opts) {
|
||||
cachedData = null;
|
||||
return mount(targetId, opts);
|
||||
}
|
||||
|
||||
function redraw(targetId, opts) {
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg || !cachedData) return;
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, opts || {});
|
||||
});
|
||||
}
|
||||
|
||||
function saveMode(modeId) {
|
||||
if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
|
||||
}
|
||||
|
||||
function getSavedMode() {
|
||||
const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
|
||||
return COMM_MODES[id] ? id : 'pyramid';
|
||||
}
|
||||
|
||||
window.AgentsMesh = {
|
||||
mount,
|
||||
refresh,
|
||||
redraw,
|
||||
pickMode,
|
||||
bindModePicker,
|
||||
setModeChangeCallback,
|
||||
loadMeshData,
|
||||
drawMesh,
|
||||
COMM_MODES,
|
||||
CLUSTERS,
|
||||
saveMode,
|
||||
getSavedMode,
|
||||
};
|
||||
|
||||
function bootPicker() {
|
||||
bindModePicker({});
|
||||
const saved = getSavedMode();
|
||||
updatePickerUI(saved);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootPicker);
|
||||
} else {
|
||||
bootPicker();
|
||||
}
|
||||
|
||||
if (window.location.pathname.indexOf('/agents') >= 0) {
|
||||
ensureMeshData().catch(function () {});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
(function () {
|
||||
const buffers = {};
|
||||
let liveEs = null;
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function lineClass(type) {
|
||||
if (!type) return 'line-action';
|
||||
if (type === 'command') return 'line-command';
|
||||
if (type === 'output') return 'line-output';
|
||||
if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
|
||||
if (type === 'error') return 'line-error';
|
||||
if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
|
||||
if (String(type).indexOf('browse') >= 0) return 'line-action';
|
||||
return 'line-action';
|
||||
}
|
||||
|
||||
function formatLine(entry) {
|
||||
const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
|
||||
const msg = entry.message || entry.title || '';
|
||||
const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
|
||||
return (
|
||||
'<div class="term-line ' +
|
||||
lineClass(entry.type) +
|
||||
'"><span class="term-ts">' +
|
||||
esc(t) +
|
||||
'</span><span class="term-msg">' +
|
||||
esc(msg + detail) +
|
||||
'</span></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function render(key) {
|
||||
const els = document.querySelectorAll('[data-term-key="' + key + '"]');
|
||||
if (!els.length) return;
|
||||
const lines = buffers[key] || [];
|
||||
const html =
|
||||
!lines.length
|
||||
? '<div class="term-line term-idle"><span class="term-msg">Typ <code>help</code> voor commando\'s · Enter om uit te voeren</span></div>'
|
||||
: lines.slice(-80).map(formatLine).join('');
|
||||
els.forEach(function (el) {
|
||||
el.innerHTML = html;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const win = el.closest('.agent-term-window');
|
||||
if (win && lines.length) win.classList.add('is-live');
|
||||
});
|
||||
}
|
||||
|
||||
function pushLine(key, entry, skipDup) {
|
||||
if (!key || !entry) return;
|
||||
key = String(key).toLowerCase();
|
||||
if (!buffers[key]) buffers[key] = [];
|
||||
if (skipDup && entry._id) {
|
||||
const seen = buffers[key].some(function (l) {
|
||||
return l._id === entry._id;
|
||||
});
|
||||
if (seen) return;
|
||||
}
|
||||
if (!entry.at) entry.at = nowIso();
|
||||
buffers[key].push(entry);
|
||||
if (buffers[key].length > 120) buffers[key].shift();
|
||||
render(key);
|
||||
}
|
||||
|
||||
function setBusy(key, busy) {
|
||||
document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
|
||||
input.disabled = !!busy;
|
||||
input.classList.toggle('is-busy', !!busy);
|
||||
});
|
||||
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
|
||||
const win = body.closest('.agent-term-window');
|
||||
if (win) win.classList.toggle('is-running', !!busy);
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(key, cmd) {
|
||||
key = String(key || '').toLowerCase();
|
||||
cmd = (cmd || '').trim();
|
||||
if (!cmd) return;
|
||||
if (cmd.toLowerCase() === 'clear') {
|
||||
buffers[key] = [];
|
||||
render(key);
|
||||
return;
|
||||
}
|
||||
pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
|
||||
setBusy(key, true);
|
||||
try {
|
||||
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err =
|
||||
(typeof data.detail === 'string' && data.detail) ||
|
||||
(Array.isArray(data.detail) && data.detail[0]?.msg) ||
|
||||
'Commando mislukt';
|
||||
pushLine(key, { type: 'error', message: err, at: nowIso() });
|
||||
return;
|
||||
}
|
||||
(data.lines || []).forEach(function (line) {
|
||||
pushLine(key, {
|
||||
type: line.type || 'output',
|
||||
message: line.message || '',
|
||||
detail: line.detail || '',
|
||||
at: nowIso(),
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
|
||||
} finally {
|
||||
setBusy(key, false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindInputs() {
|
||||
document.querySelectorAll('[data-term-input]').forEach(function (input) {
|
||||
if (input._termBound) return;
|
||||
input._termBound = true;
|
||||
const key = input.getAttribute('data-term-input');
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const val = input.value;
|
||||
input.value = '';
|
||||
runCommand(key, val);
|
||||
}
|
||||
});
|
||||
input.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory(key) {
|
||||
try {
|
||||
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
buffers[key] = (data.items || []).map(function (ev) {
|
||||
const meta = ev.metadata || {};
|
||||
let type = 'action';
|
||||
const et = ev.event_type || '';
|
||||
if (et.indexOf('handoff') >= 0) type = 'handoff_out';
|
||||
if (meta.target_agent) type = 'handoff_out';
|
||||
if (meta.source_agent) type = 'handoff_in';
|
||||
if (et.indexOf('monitor') >= 0) type = 'monitor';
|
||||
if (et.indexOf('browse') >= 0) type = 'browse';
|
||||
if (et === 'terminal_in') type = 'command';
|
||||
if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
|
||||
return {
|
||||
_id: ev.id,
|
||||
type: type,
|
||||
message: ev.title || ev.event_type,
|
||||
detail: ev.body,
|
||||
at: ev.created_at,
|
||||
};
|
||||
});
|
||||
render(key);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function handleLivePayload(d) {
|
||||
if (!d || d.type === 'connected' || d.type === 'error') return;
|
||||
const key = (d.agent || '').toLowerCase();
|
||||
if (!key) return;
|
||||
pushLine(
|
||||
key,
|
||||
{
|
||||
_id: d.id,
|
||||
type: d.type,
|
||||
message: d.message,
|
||||
detail: d.detail,
|
||||
at: d.at,
|
||||
},
|
||||
true
|
||||
);
|
||||
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
|
||||
const win = el.closest('.agent-term-window');
|
||||
if (win) win.classList.add('is-live');
|
||||
});
|
||||
}
|
||||
|
||||
function startLiveStream() {
|
||||
if (liveEs || typeof EventSource === 'undefined') return;
|
||||
liveEs = new EventSource('/api/agents/live/stream');
|
||||
liveEs.onmessage = function (ev) {
|
||||
try {
|
||||
handleLivePayload(JSON.parse(ev.data));
|
||||
} catch (e) {}
|
||||
};
|
||||
liveEs.onerror = function () {
|
||||
/* EventSource auto-reconnects */
|
||||
};
|
||||
}
|
||||
|
||||
function stopLiveStream() {
|
||||
if (liveEs) {
|
||||
try {
|
||||
liveEs.close();
|
||||
} catch (e) {}
|
||||
liveEs = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mountAll() {
|
||||
const nodes = document.querySelectorAll('[data-term-key]');
|
||||
const keys = [];
|
||||
nodes.forEach(function (n) {
|
||||
const k = n.getAttribute('data-term-key');
|
||||
if (k && keys.indexOf(k) < 0) keys.push(k);
|
||||
});
|
||||
await Promise.all(keys.map(loadHistory));
|
||||
bindInputs();
|
||||
startLiveStream();
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
stopLiveStream();
|
||||
}
|
||||
|
||||
window.AgentsTerminals = {
|
||||
mountAll,
|
||||
stopAll,
|
||||
pushLine,
|
||||
render,
|
||||
runCommand,
|
||||
bindInputs,
|
||||
startLiveStream,
|
||||
stopLiveStream,
|
||||
};
|
||||
})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -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,7 +3,6 @@
|
||||
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
||||
executive: { title: 'Alles op een rij', icon: '📋' },
|
||||
briefing: { title: 'Herman briefing', icon: '📝' },
|
||||
rss: { title: 'RSS live feed', icon: '📰' },
|
||||
retail: { title: 'Retail operatie', icon: '🏪' },
|
||||
analytics: { title: 'Data & analytics', icon: '📈' },
|
||||
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
||||
@@ -17,9 +16,9 @@
|
||||
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
|
||||
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
|
||||
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
|
||||
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
|
||||
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
|
||||
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
|
||||
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/?tab=rss' },
|
||||
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/?tab=rss' },
|
||||
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing?tab=bookmarks' },
|
||||
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
|
||||
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
|
||||
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
|
||||
(function () {
|
||||
var DEFAULT = 'overview';
|
||||
|
||||
function radioId(tab) {
|
||||
return 'doctab-' + tab;
|
||||
}
|
||||
|
||||
function getActiveTab() {
|
||||
var checked = document.querySelector('.doc-tab-input:checked');
|
||||
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
|
||||
}
|
||||
|
||||
function show(tab) {
|
||||
if (!tab) tab = DEFAULT;
|
||||
var radio = document.getElementById(radioId(tab));
|
||||
if (radio) radio.checked = true;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
window.__docActiveTab = tab;
|
||||
}
|
||||
|
||||
function syncAlpine(tab) {
|
||||
var hub = document.querySelector('.doc-hub');
|
||||
if (!hub || !window.Alpine) return;
|
||||
try {
|
||||
var d = Alpine.$data(hub);
|
||||
if (!d) return;
|
||||
if (d.activeTab !== tab) d.activeTab = tab;
|
||||
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
|
||||
} catch (e) {
|
||||
console.warn('doc-tabs alpine', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
var tab = getActiveTab();
|
||||
window.__docActiveTab = tab;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
syncAlpine(tab);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
|
||||
if (radio.dataset.bound) return;
|
||||
radio.dataset.bound = '1';
|
||||
radio.addEventListener('change', onTabChange);
|
||||
});
|
||||
}
|
||||
|
||||
window.DocTabs = { show: show, getActive: getActiveTab };
|
||||
|
||||
bind();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
});
|
||||
document.addEventListener('alpine:initialized', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
syncAlpine(getActiveTab());
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function row(label, val, link) {
|
||||
if (!val) return '';
|
||||
var inner = link
|
||||
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
|
||||
: escapeHtml(val);
|
||||
return (
|
||||
'<div class="ei-popup-row">' +
|
||||
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
|
||||
'<span class="ei-popup-v">' + inner + '</span>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function halalColor(p) {
|
||||
var tier = p.halal_tier || 'low';
|
||||
return HALAL_COLORS[tier] || HALAL_COLORS.low;
|
||||
}
|
||||
|
||||
function popupHtml(p) {
|
||||
var c = cfg(p.entity_type);
|
||||
var id = p.id;
|
||||
var web = p.website;
|
||||
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
|
||||
var halalRow = '';
|
||||
if (p.halal_score != null) {
|
||||
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
|
||||
}
|
||||
var contactBits = [];
|
||||
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
|
||||
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
|
||||
var contactRow = contactBits.length
|
||||
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
|
||||
: '';
|
||||
return (
|
||||
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
|
||||
'<div class="ei-popup-top">' +
|
||||
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
|
||||
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
|
||||
'</div>' +
|
||||
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
|
||||
'<div class="ei-popup-grid">' +
|
||||
halalRow +
|
||||
contactRow +
|
||||
row('Stad', p.city) +
|
||||
row('Adres', p.address_line) +
|
||||
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
|
||||
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
|
||||
row('Website', p.website, web) +
|
||||
row('Volume', p.volume_band) +
|
||||
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
|
||||
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
|
||||
row('Pipeline', p.pipeline_stage) +
|
||||
'</div>' +
|
||||
'<div class="ei-popup-actions">' +
|
||||
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function pinIcon(type, p) {
|
||||
var c = cfg(type);
|
||||
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
|
||||
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
|
||||
return L.divIcon({
|
||||
className: 'ei-pin-wrap',
|
||||
html:
|
||||
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
|
||||
'<span class="ei-pin-ring"></span>' +
|
||||
'<span class="ei-pin-icon">' + c.icon + '</span>' +
|
||||
'</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
}
|
||||
|
||||
function openEntityId(rawId) {
|
||||
var id = Number(rawId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
if (onEntityClick) {
|
||||
onEntityClick(id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
|
||||
} catch (e) { /* empty */ }
|
||||
}
|
||||
|
||||
function wirePopup(container) {
|
||||
if (!container) return;
|
||||
var popupRoot = container.closest('.leaflet-popup') || container;
|
||||
if (window.L && L.DomEvent) {
|
||||
L.DomEvent.disableClickPropagation(popupRoot);
|
||||
L.DomEvent.disableScrollPropagation(popupRoot);
|
||||
}
|
||||
var btn = container.querySelector('.ei-popup-btn');
|
||||
var id = btn && btn.getAttribute('data-entity-id');
|
||||
if (btn && id) {
|
||||
var onBtn = function (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
openEntityId(id);
|
||||
};
|
||||
if (window.L && L.DomEvent) {
|
||||
L.DomEvent.on(btn, 'click', onBtn);
|
||||
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
|
||||
} else {
|
||||
btn.addEventListener('click', onBtn);
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
}
|
||||
container.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
|
||||
var card = e.target.closest('[data-entity-id]');
|
||||
if (card) openEntityId(card.getAttribute('data-entity-id'));
|
||||
});
|
||||
}
|
||||
|
||||
function 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: '© OSM © 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 };
|
||||
})();
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Herman Assistant — browser widget + gedeelde chat/voice logica
|
||||
*/
|
||||
window.HermanAssistant = (function () {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function entityTypeLabel(t) {
|
||||
var m = {
|
||||
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
|
||||
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
|
||||
butcher: 'Slager', doner: 'Döner',
|
||||
};
|
||||
return m[t] || t || '—';
|
||||
}
|
||||
|
||||
function newSessionId(prefix) {
|
||||
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
function baseState(opts) {
|
||||
opts = opts || {};
|
||||
return {
|
||||
channel: opts.channel || 'browser',
|
||||
sessionPrefix: opts.sessionPrefix || 'br',
|
||||
open: false,
|
||||
hidden: false,
|
||||
status: 'idle',
|
||||
statusDetail: '',
|
||||
livePreview: '',
|
||||
liveInterim: false,
|
||||
recording: false,
|
||||
busy: false,
|
||||
speakReply: true,
|
||||
handsFreeMode: false,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
turns: [],
|
||||
manualText: '',
|
||||
pendingAction: null,
|
||||
resultsOpen: false,
|
||||
resultsTitle: '',
|
||||
resultsTotal: 0,
|
||||
resultsEntities: [],
|
||||
resultsOpenUrl: '',
|
||||
webbuilderOpen: false,
|
||||
webbuilderTitle: '',
|
||||
webbuilderProject: '',
|
||||
webbuilderPreview: '',
|
||||
webbuilderAgentsUrl: '/agents',
|
||||
webbuilderNas: '',
|
||||
sessionId: newSessionId(opts.sessionPrefix || 'br'),
|
||||
mediaRecorder: null,
|
||||
chunks: [],
|
||||
stream: null,
|
||||
recognition: null,
|
||||
holdIgnoreClick: false,
|
||||
_resumeTimer: null,
|
||||
_silenceTimer: null,
|
||||
_maxRecordTimer: null,
|
||||
|
||||
initWidget: function () { /* overridden in browserWidget */ },
|
||||
|
||||
toggleOpen: function () {
|
||||
this.open = !this.open;
|
||||
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
scrollLog: function () {
|
||||
var self = this;
|
||||
this.$nextTick(function () {
|
||||
var log = self.$refs.chatLog;
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
},
|
||||
|
||||
addTurn: function (role, text, meta) {
|
||||
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
|
||||
this.scrollLog();
|
||||
},
|
||||
|
||||
handleHermanResponse: function (h) {
|
||||
if (!h) return;
|
||||
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
|
||||
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
|
||||
if (h.reply) {
|
||||
this.addTurn('agent', h.reply, {
|
||||
agent_label: h.agent_label,
|
||||
delegated: h.delegated_agents,
|
||||
routing_reason: h.routing_reason,
|
||||
});
|
||||
}
|
||||
var actions = h.ui_actions || [];
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
|
||||
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
|
||||
}
|
||||
if (!actions.length && h.webbuilder_preview_url) {
|
||||
this.openWebbuilderModal({
|
||||
type: 'open_webbuilder_build',
|
||||
title: 'Website build — ' + (h.webbuilder_project || 'project'),
|
||||
project: h.webbuilder_project,
|
||||
preview_url: h.webbuilder_preview_url,
|
||||
agents_url: '/agents',
|
||||
});
|
||||
}
|
||||
if (this.speakReply && h.reply && window.speechSynthesis) {
|
||||
var self = this;
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
|
||||
}
|
||||
},
|
||||
|
||||
maybeResumeListening: function (delay) {
|
||||
var self = this;
|
||||
if (!this.handsFreeMode || this.busy || this.recording) return;
|
||||
if (this._resumeTimer) clearTimeout(this._resumeTimer);
|
||||
this._resumeTimer = setTimeout(function () {
|
||||
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
|
||||
}, delay || 500);
|
||||
},
|
||||
|
||||
openResultsModal: function (action) {
|
||||
this.resultsTitle = action.title || 'Export Intel';
|
||||
this.resultsEntities = action.entities || [];
|
||||
this.resultsTotal = action.total || this.resultsEntities.length;
|
||||
this.resultsOpenUrl = action.open_url || '/export-intel';
|
||||
this.resultsOpen = true;
|
||||
},
|
||||
|
||||
closeResultsModal: function () { this.resultsOpen = false; },
|
||||
|
||||
openWebbuilderModal: function (action) {
|
||||
this.webbuilderTitle = action.title || 'Website build';
|
||||
this.webbuilderProject = action.project || '';
|
||||
this.webbuilderPreview = action.preview_url || '';
|
||||
this.webbuilderAgentsUrl = action.agents_url || '/agents';
|
||||
this.webbuilderNas = action.nas_path || '';
|
||||
this.webbuilderOpen = true;
|
||||
},
|
||||
|
||||
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
|
||||
|
||||
confirmPending: function () {
|
||||
if (!this.pendingAction || !this.pendingAction.id) return;
|
||||
this.manualText = 'ja';
|
||||
this.sendText(this.pendingAction.id);
|
||||
},
|
||||
|
||||
cancelPending: function () {
|
||||
this.pendingAction = null;
|
||||
this.manualText = 'nee';
|
||||
this.sendText();
|
||||
},
|
||||
|
||||
entityTypeLabel: entityTypeLabel,
|
||||
|
||||
_startRecognition: function () {
|
||||
if (!this.useBrowserPreview || !SpeechRecognition) return;
|
||||
var self = this;
|
||||
try {
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'nl-NL';
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.onresult = function (e) {
|
||||
var interim = '', final = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) final += e.results[i][0].transcript;
|
||||
else interim += e.results[i][0].transcript;
|
||||
}
|
||||
self.liveInterim = !!interim && !final;
|
||||
self.livePreview = final || interim;
|
||||
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
|
||||
};
|
||||
this.recognition.start();
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
_stopRecognition: function () {
|
||||
if (this.recognition) {
|
||||
try { this.recognition.stop(); } catch (e) { /* empty */ }
|
||||
this.recognition = null;
|
||||
}
|
||||
},
|
||||
|
||||
_stopStream: function () {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
this.stream = null;
|
||||
}
|
||||
},
|
||||
|
||||
_scheduleSilenceStop: function () {
|
||||
var self = this;
|
||||
if (this._silenceTimer) clearTimeout(this._silenceTimer);
|
||||
this._silenceTimer = setTimeout(function () {
|
||||
if (self.recording && self.handsFreeMode) self.stopListen();
|
||||
}, 1600);
|
||||
},
|
||||
|
||||
async startListen() {
|
||||
if (this.recording || this.busy) return;
|
||||
var self = this;
|
||||
this.livePreview = '';
|
||||
this.status = 'listening';
|
||||
this.statusDetail = 'Luisteren…';
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.chunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.mediaRecorder.ondataavailable = function (e) {
|
||||
if (e.data && e.data.size) self.chunks.push(e.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = function () {
|
||||
self._stopRecognition();
|
||||
self._stopStream();
|
||||
if (self._silenceTimer) clearTimeout(self._silenceTimer);
|
||||
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
|
||||
var blob = new Blob(self.chunks, { type: 'audio/webm' });
|
||||
self.recording = false;
|
||||
if (blob.size > 0) self.processVoice(blob);
|
||||
else { self.status = 'idle'; self.statusDetail = ''; }
|
||||
};
|
||||
this.mediaRecorder.start(250);
|
||||
this.recording = true;
|
||||
this._startRecognition();
|
||||
if (this.handsFreeMode) {
|
||||
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
|
||||
this._maxRecordTimer = setTimeout(function () {
|
||||
if (self.recording) self.stopListen();
|
||||
}, 18000);
|
||||
}
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast('Microfoon: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
stopListen: function () {
|
||||
if (!this.recording || !this.mediaRecorder) return;
|
||||
this.mediaRecorder.stop();
|
||||
},
|
||||
|
||||
toggleListen: function () {
|
||||
if (this.recording) this.stopListen();
|
||||
else this.startListen();
|
||||
},
|
||||
|
||||
micClass: function () {
|
||||
if (this.recording) return 'is-listening';
|
||||
if (this.busy) return 'is-busy';
|
||||
return '';
|
||||
},
|
||||
|
||||
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
|
||||
onMicUp: function () {
|
||||
if (this.recording && !this.handsFreeMode) this.stopListen();
|
||||
var self = this;
|
||||
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
|
||||
},
|
||||
onMicClick: function () {
|
||||
if (this.holdIgnoreClick) return;
|
||||
this.toggleListen();
|
||||
},
|
||||
|
||||
async processVoice(blob) {
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Whisper + Herman…';
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
|
||||
fd.append('session_id', this.sessionId);
|
||||
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
|
||||
var j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
|
||||
var text = j.text || '';
|
||||
this.livePreview = text;
|
||||
this.manualText = text;
|
||||
if (text) this.addTurn('user', text, { source: 'voice' });
|
||||
this.handleHermanResponse(j.herman || {});
|
||||
this.status = 'idle';
|
||||
this.statusDetail = '';
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
async sendText(confirmActionId) {
|
||||
var text = (this.manualText || '').trim();
|
||||
if (!text) return;
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
try {
|
||||
var body = { message: text, channel: this.channel, session_id: this.sessionId };
|
||||
if (confirmActionId) body.confirm_action_id = confirmActionId;
|
||||
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
|
||||
this.addTurn('user', text, { source: 'text' });
|
||||
this.manualText = '';
|
||||
this.handleHermanResponse(r);
|
||||
this.status = 'idle';
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.status = 'error';
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
formatDelegated: function (meta) {
|
||||
var d = (meta && meta.delegated) || [];
|
||||
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
browserWidget: function () {
|
||||
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
|
||||
s.initWidget = function () {
|
||||
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
|
||||
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
|
||||
try {
|
||||
var raw = localStorage.getItem('herman_assistant_open');
|
||||
if (raw === '1') this.open = true;
|
||||
} catch (e) { /* empty */ }
|
||||
};
|
||||
return s;
|
||||
},
|
||||
chatPage: function () {
|
||||
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
|
||||
s.imagePrompt = '';
|
||||
s.generating = false;
|
||||
s.lastImage = '';
|
||||
s.initChat = function () {
|
||||
this.open = true;
|
||||
};
|
||||
s.generateImage = async function () {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
this.generating = true;
|
||||
try {
|
||||
var r = await Cockpit.api('/api/ai/generate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 }),
|
||||
});
|
||||
if (r.proxy_url) {
|
||||
this.lastImage = r.proxy_url;
|
||||
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
|
||||
}
|
||||
Cockpit.toast('Afbeelding gegenereerd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.generating = false;
|
||||
};
|
||||
return s;
|
||||
},
|
||||
entityTypeLabel: entityTypeLabel,
|
||||
};
|
||||
})();
|
||||
|
||||
function hermanBrowserWidget() {
|
||||
return HermanAssistant.browserWidget();
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -17,7 +17,14 @@ window.I18n = (function () {
|
||||
function applyDom() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
var k = el.getAttribute('data-i18n');
|
||||
if (k) el.textContent = t(k);
|
||||
if (!k) return;
|
||||
var text = t(k);
|
||||
/* Sidebar nav: never replace whole link (destroys icons) */
|
||||
if (el.classList && el.classList.contains('nav-pill')) {
|
||||
var lbl = el.querySelector('.sidebar-nav-label');
|
||||
if (lbl) { lbl.textContent = text; return; }
|
||||
}
|
||||
el.textContent = text;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||
var k = el.getAttribute('data-i18n-placeholder');
|
||||
@@ -31,7 +38,7 @@ window.I18n = (function () {
|
||||
}
|
||||
|
||||
async function loadBundle(loc) {
|
||||
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
|
||||
var r = await fetch('/static/i18n/' + loc + '.json?v=3');
|
||||
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
|
||||
strings = await r.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function pageMatches(page, filters) {
|
||||
const q = (filters.search || '').trim().toLowerCase();
|
||||
const terms = filters.terms || [];
|
||||
const siteId = filters.siteId ? String(filters.siteId) : '';
|
||||
const minWords = parseInt(filters.minWords, 10) || 0;
|
||||
const minHype = parseInt(filters.minHype, 10) || 0;
|
||||
|
||||
if (siteId && String(page.site_id) !== siteId) return false;
|
||||
if ((page.word_count || 0) < minWords) return false;
|
||||
if ((page.hype_score || 0) < minHype) return false;
|
||||
if (filters.changedOnly && !page.recently_changed) return false;
|
||||
|
||||
const hay = [
|
||||
page.site_name,
|
||||
page.title,
|
||||
page.url,
|
||||
page.excerpt,
|
||||
page.content_read,
|
||||
page.meta_description,
|
||||
(page.headings || []).join(' '),
|
||||
(page.signals || []).join(' '),
|
||||
(page.links_sample || []).map(function (l) {
|
||||
return (l.label || '') + ' ' + (l.href || '');
|
||||
}).join(' '),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (q && hay.indexOf(q) < 0) return false;
|
||||
|
||||
for (let i = 0; i < terms.length; i++) {
|
||||
if (hay.indexOf(String(terms[i]).toLowerCase()) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortPages(pages, col, dir) {
|
||||
const mul = dir === 'asc' ? 1 : -1;
|
||||
const copy = pages.slice();
|
||||
copy.sort(function (a, b) {
|
||||
let va;
|
||||
let vb;
|
||||
switch (col) {
|
||||
case 'site_name':
|
||||
va = (a.site_name || '').toLowerCase();
|
||||
vb = (b.site_name || '').toLowerCase();
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'title':
|
||||
va = (a.title || a.url || '').toLowerCase();
|
||||
vb = (b.title || b.url || '').toLowerCase();
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'word_count':
|
||||
return ((a.word_count || 0) - (b.word_count || 0)) * mul;
|
||||
case 'links_count':
|
||||
return ((a.links_count || 0) - (b.links_count || 0)) * mul;
|
||||
case 'crawled_at':
|
||||
va = a.crawled_at || '';
|
||||
vb = b.crawled_at || '';
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'hype_score':
|
||||
default:
|
||||
return ((a.hype_score || 0) - (b.hype_score || 0)) * mul;
|
||||
}
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function filterPages(pages, filters) {
|
||||
if (!pages || !pages.length) return [];
|
||||
return pages.filter(function (p) {
|
||||
return pageMatches(p, filters);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightHtml(text, queries) {
|
||||
const raw = text || '';
|
||||
if (!raw) return '<span class="muted">Geen tekst</span>';
|
||||
let html = esc(raw);
|
||||
const list = [];
|
||||
if (queries && queries.length) {
|
||||
queries.forEach(function (q) {
|
||||
if (q && String(q).trim()) list.push(String(q).trim());
|
||||
});
|
||||
}
|
||||
list.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
list.forEach(function (term) {
|
||||
if (term.length < 2) return;
|
||||
const re = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||||
html = html.replace(re, '<mark>$1</mark>');
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function exportCsv(pages) {
|
||||
const rows = [['site', 'title', 'url', 'words', 'hype', 'changed', 'crawled_at', 'excerpt']];
|
||||
pages.forEach(function (p) {
|
||||
rows.push([
|
||||
p.site_name || '',
|
||||
(p.title || '').replace(/"/g, '""'),
|
||||
p.url || '',
|
||||
String(p.word_count || 0),
|
||||
String(p.hype_score || 0),
|
||||
p.recently_changed ? 'yes' : 'no',
|
||||
p.crawled_at || '',
|
||||
(p.excerpt || '').replace(/"/g, '""').slice(0, 500),
|
||||
]);
|
||||
});
|
||||
return rows
|
||||
.map(function (r) {
|
||||
return r.map(function (c) {
|
||||
return '"' + c + '"';
|
||||
}).join(',');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function downloadCsv(pages, filename) {
|
||||
const csv = exportCsv(pages);
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename || 'parse-analyse.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
window.ParseIntel = {
|
||||
filterPages: filterPages,
|
||||
sortPages: sortPages,
|
||||
highlightHtml: highlightHtml,
|
||||
exportCsv: exportCsv,
|
||||
downloadCsv: downloadCsv,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,422 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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(', ');
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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 & 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());
|
||||
|
||||
+200
-59
@@ -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="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="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" 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">
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
|
||||
<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 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="/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">
|
||||
<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>
|
||||
</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
@@ -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' });
|
||||
|
||||
+464
-43
@@ -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()">
|
||||
<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>
|
||||
<select x-model="storeProvince" @change="searchStores()">
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
|
||||
</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 & 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 & 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'); }
|
||||
},
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 & M&A</option>
|
||||
<option value="regelgeving">Regelgeving</option>
|
||||
<option value="cbs">CBS</option>
|
||||
</select>
|
||||
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
|
||||
</div>
|
||||
|
||||
<div class="herman-rss-grid">
|
||||
<section class="herman-rss-col herman-rss-col-main">
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
|
||||
<div class="herman-rss-list">
|
||||
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
|
||||
<article class="feed-card herman-rss-card">
|
||||
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
|
||||
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
|
||||
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
|
||||
<div class="feed-actions">
|
||||
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
|
||||
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
|
||||
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="herman-rss-col herman-rss-col-side">
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
|
||||
<h4>Regelgeving</h4>
|
||||
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
|
||||
<h4>CBS & data</h4>
|
||||
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
|
||||
<small x-text="r.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section class="hm-chart-panel" x-show="rssBookmarks.length">
|
||||
<h4>★ Opgeslagen</h4>
|
||||
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
|
||||
<div class="herman-rss-side-item">
|
||||
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
|
||||
<small x-text="b.feed_name"></small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=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);
|
||||
|
||||
+544
-432
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user