Compare commits

...

8 Commits

Author SHA1 Message Date
Aissa c5ea8cbb63 Ship War Room UX, CEO inbox, mobile chat, and Agents NAS workspace.
Make A2A togglable, keep reply context while reading, route agent data to //10.4.7.11/share/Agents, and polish themes plus export-intel sync cancel.
2026-08-16 22:40:32 +00:00
Aissa 910ba465d7 Add War Room voice meeting, fix chat overlay, and ship 5 cyan UI themes.
Voice Room lets humans talk with agents via Whisper/TTS; chat typing no longer blocked by a full-screen overlay, attachments/avatars are larger, and Settings exposes five vivid mid-dark cyan/blue themes for the whole cockpit.
2026-08-16 15:49:29 +00:00
sysops 1dd607b060 SysOps: approved_backup — 2026-08-16 11:01 UTC 2026-08-16 11:01:58 +00:00
Aissa 22a17ed048 Activate agent mesh with honest live status and working animations.
Souls now aggregate aliased events (e.g. halal_engine), KPIs reflect 6h activity, active agents animate, and morning scheduler/scripts target cockpit :8610.
2026-08-01 17:59:32 +00:00
Aissa 5e43b6b246 Ship Herman chat, Hermes KPI explorer, and intelligence analytics.
Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
2026-08-01 17:50:45 +00:00
Aissa 62239f0a4e Improve CEO cockpit clarity and Revenue visuals, with Hermes briefing.
Tighten overview KPIs/pipeline height, add selectable Revenue views and a resizable sidebar, and keep supporting Hermes/Wereldexport updates.
2026-07-30 11:54:54 +00:00
sysops 5dd69a1138 SysOps: manual — 2026-07-30 09:33 UTC 2026-07-30 09:33:03 +00:00
aissa f21d58483b refactor: cleaner briefing layout - business sections, no IT clutter 2026-07-30 09:28:03 +00:00
102 changed files with 17103 additions and 2687 deletions
+10
View File
@@ -21,3 +21,13 @@ PROXMOX_HOST=10.4.7.14
PROXMOX_USER=root@pam
PROXMOX_TOKEN_NAME=
PROXMOX_TOKEN_VALUE=
# OpenRouter (optioneel — ook via Instellingen → AI / LLM in cockpit)
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# orchestrator | router | auto (auto = orchestrator met fallback naar standaard LLM)
HERMAN_LLM_BACKEND=auto
# Google Places (Export Intel — telefoon/website verrijking)
GOOGLE_MAPS_API_KEY=
+6
View File
@@ -16,3 +16,9 @@ cockpit/static/uploads/
.cursor/
tmp/
cockpit/tmp-upload/
# Local TLS private keys (self-signed cockpit HTTPS)
cockpit/caddy/certs/*.key
cockpit/caddy/certs/key.pem
cockpit/caddy/certs/*.srl
cockpit/caddy/certs/*.csr
+292
View File
@@ -0,0 +1,292 @@
"""Cockpit user accounts: password hashing, CRUD, permissions."""
from __future__ import annotations
import hashlib
import json
import secrets
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
# Nav / feature keys shown in Settings → Gebruikers
PAGE_CATALOG: list[dict[str, str]] = [
{"key": "dashboard", "label": "Overzicht (CEO)"},
{"key": "revenue", "label": "Revenue Cockpit"},
{"key": "retail", "label": "Retail NL"},
{"key": "marketing", "label": "Marketing"},
{"key": "packaging", "label": "Packaging"},
{"key": "beurs", "label": "Beurs Live"},
{"key": "documents", "label": "Documenten"},
{"key": "projects", "label": "Projecten"},
{"key": "hermes", "label": "Telegram / Hermes"},
{"key": "analytics", "label": "Analytics"},
{"key": "export", "label": "Wereldexport"},
{"key": "clients", "label": "Klanten"},
{"key": "deals", "label": "Deals"},
{"key": "products", "label": "Producten"},
{"key": "suppliers", "label": "Leveranciers"},
{"key": "reports", "label": "Rapporten"},
{"key": "ops", "label": "IT Ops"},
{"key": "agents", "label": "Agents"},
{"key": "browser", "label": "Browser"},
{"key": "studio", "label": "Studio"},
{"key": "voice", "label": "Voice"},
{"key": "settings", "label": "Instellingen"},
]
# path prefix → permission key (order: longest prefix first)
PATH_RULES: list[tuple[str, str]] = [
("/revenue-cockpit", "revenue"),
("/export-intel", "export"),
("/packaging", "packaging"),
("/marketing", "marketing"),
("/documents", "documents"),
("/analytics", "analytics"),
("/suppliers", "suppliers"),
("/products", "products"),
("/projects", "projects"),
("/settings", "settings"),
("/clients", "clients"),
("/browser", "browser"),
("/monitor", "browser"),
("/hermes", "hermes"),
("/retail", "retail"),
("/reports", "reports"),
("/studio", "studio"),
("/agents", "agents"),
("/beurs", "beurs"),
("/deals", "deals"),
("/voice", "voice"),
("/ops", "ops"),
("/", "dashboard"),
]
ADMIN_SEED_USERNAME = "aissa"
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
rounds = 200_000
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algo, rounds_s, salt, digest = password_hash.split("$", 3)
if algo != "pbkdf2_sha256":
return False
rounds = int(rounds_s)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return secrets.compare_digest(dk.hex(), digest)
except Exception:
return False
def _normalize_perms(raw: Any) -> dict[str, Any]:
if raw is None:
return {"pages": [], "manage_users": False}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return {"pages": [], "manage_users": False}
if not isinstance(raw, dict):
return {"pages": [], "manage_users": False}
pages = raw.get("pages") or []
if not isinstance(pages, list):
pages = []
return {
"pages": [str(p) for p in pages],
"manage_users": bool(raw.get("manage_users")),
}
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
perms = _normalize_perms(row.get("permissions"))
role = row.get("role") or "user"
if role == "admin":
perms = {
"pages": [p["key"] for p in PAGE_CATALOG],
"manage_users": True,
}
return {
"id": row["id"],
"username": row["username"],
"display_name": row.get("display_name") or row["username"],
"role": role,
"is_active": bool(row.get("is_active", True)),
"avatar_url": row.get("avatar_url") or None,
"permissions": perms,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
}
def ensure_admin_seed() -> None:
"""Create default admin aissa if table empty / user missing."""
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
if row:
return
execute(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, 'admin', %s::jsonb)
ON CONFLICT (username) DO NOTHING
""",
(
ADMIN_SEED_USERNAME,
hash_password(ADMIN_SEED_PASSWORD),
"Aissa",
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
),
)
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
(username.strip(),),
)
if not row or not row.get("is_active"):
return None
if not verify_password(password, row["password_hash"]):
return None
execute(
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(row["id"],),
)
return public_user(row)
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
return public_user(row)
def list_users() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
return [public_user(r) for r in rows if r]
def create_user(
username: str,
password: str,
display_name: str | None,
role: str,
permissions: dict[str, Any],
) -> dict[str, Any]:
username = username.strip()
if not username or not password:
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
if role not in ("admin", "user"):
role = "user"
perms = _normalize_perms(permissions)
if role == "admin":
perms["manage_users"] = True
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
row = fetch_one(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, %s, %s::jsonb)
RETURNING *
""",
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
)
return public_user(row) # type: ignore
def update_user(
user_id: int,
*,
display_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
permissions: dict[str, Any] | None = None,
password: str | None = None,
) -> dict[str, Any]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
new_name = display_name if display_name is not None else row.get("display_name")
new_role = role if role in ("admin", "user") else row["role"]
new_active = row["is_active"] if is_active is None else bool(is_active)
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
if new_role == "admin":
new_perms["manage_users"] = True
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
pw_hash = row["password_hash"]
if password:
pw_hash = hash_password(password)
updated = fetch_one(
"""
UPDATE cockpit_users
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
password_hash = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
)
return public_user(updated) # type: ignore
def delete_user(user_id: int, *, actor_id: int) -> None:
if user_id == actor_id:
raise ValueError("Je kunt jezelf niet verwijderen")
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
if row["role"] == "admin":
admins = fetch_one(
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
)
if admins and admins["n"] <= 1:
raise ValueError("Laatste admin kan niet verwijderd worden")
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
pages = set((user.get("permissions") or {}).get("pages") or [])
# normalize
p = path.split("?")[0] or "/"
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
for prefix, key in PATH_RULES:
if prefix == "/":
if p == "/" or p == "":
return "dashboard" in pages
continue
if p == prefix or p.startswith(prefix + "/"):
return key in pages
return "dashboard" in pages
def can_manage_users(user: dict[str, Any] | None) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
return bool((user.get("permissions") or {}).get("manage_users"))
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
row = fetch_one(
"""
UPDATE cockpit_users
SET avatar_url = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(avatar_url, user_id),
)
if not row:
raise ValueError("Gebruiker niet gevonden")
return public_user(row) # type: ignore
+18
View File
@@ -10,13 +10,31 @@ class Settings:
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
# OpenRouter (optioneel — key ook via Instellingen → AI / LLM)
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-v4-flash")
OPENROUTER_BASE_URL: str = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
# orchestrator | router | auto (auto = orchestrator, fallback naar standaard LLM provider)
HERMAN_LLM_BACKEND: str = os.getenv("HERMAN_LLM_BACKEND", "auto")
# Hermes Agent (OpenAI-compatible) — Herman briefing backend
HERMES_API_BASE: str = os.getenv("HERMES_API_BASE", "http://10.4.7.10:8642/v1")
HERMES_API_KEY: str = os.getenv("HERMES_API_KEY", "")
HERMES_API_MODEL: str = os.getenv("HERMES_API_MODEL", "hermes-agent")
WARROOM_MEET_BASE: str = os.getenv("WARROOM_MEET_BASE", "https://meet.jit.si")
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
# Bind cockpit chat aan Hermes/Telegram memory (chat_id als OpenAI "user")
HERMES_CEO_CHAT_ID: str = os.getenv("HERMES_CEO_CHAT_ID", "8859782446")
HERMES_CTO_CHAT_ID: str = os.getenv("HERMES_CTO_CHAT_ID", "789036463")
HERMES_MEMORY_LIMIT: int = int(os.getenv("HERMES_MEMORY_LIMIT", "24"))
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
SESSION_SECRET: str = os.getenv("SESSION_SECRET", "foodlinkk-cockpit-session-change-me-2026")
SESSION_MAX_AGE: int = int(os.getenv("SESSION_MAX_AGE", str(60 * 60 * 24 * 14))) # 14 days
@property
def database_dsn(self) -> str:
+137
View File
@@ -3,6 +3,8 @@ import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import Response
@@ -38,6 +40,9 @@ from app.routes import (
from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes import auth_routes
from app import auth_users
from app.config import settings as app_settings
from app.routes.agents_api import router as agents_api_router
from app.routes.marketing_api import router as marketing_api_router
from app.routes.projects_api import router as projects_api_router
@@ -59,6 +64,131 @@ class NoCacheStaticFiles(StaticFiles):
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
PUBLIC_PREFIXES = (
"/login",
"/logout",
"/static/",
"/sw.js",
"/manifest.webmanifest",
"/static/manifest.json",
"/api/auth/login",
)
PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"}
def _is_public(path: str) -> bool:
if path in PUBLIC_EXACT:
return True
for pref in PUBLIC_PREFIXES:
if path == pref or path.startswith(pref):
return True
return False
class AuthGateMiddleware:
"""Require login; must run *inside* SessionMiddleware."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
from starlette.requests import Request
request = Request(scope, receive=receive)
path = request.url.path
user = None
try:
uid = request.session.get("user_id")
except Exception:
uid = None
if uid:
try:
user = auth_users.get_user_by_id(int(uid))
except Exception:
user = None
if not user or not user.get("is_active"):
try:
request.session.clear()
except Exception:
pass
user = None
request.state.user = user
async def call_next():
await self.app(scope, receive, send)
if _is_public(path):
await self.app(scope, receive, send)
return
if not user:
if path.startswith("/api/") or path.startswith("/ws"):
response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401)
else:
nxt = path
if request.url.query:
nxt = f"{path}?{request.url.query}"
from urllib.parse import quote
response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303)
await response(scope, receive, send)
return
if (
not path.startswith("/api/")
and not path.startswith("/ws")
and not path.startswith("/static")
and not auth_users.path_allowed(user, path)
):
response = RedirectResponse("/", status_code=303)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
# Order: first added = innermost. Session must be outermost so request.session works.
app.add_middleware(AuthGateMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=app_settings.SESSION_SECRET,
session_cookie="flk_session",
same_site="lax",
https_only=False,
max_age=app_settings.SESSION_MAX_AGE,
)
@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False)
async def service_worker():
"""Root-scoped service worker for installable PWA (Android + iOS Safari)."""
sw_path = BASE_DIR / "static" / "sw.js"
return FileResponse(
sw_path,
media_type="application/javascript; charset=utf-8",
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False)
async def web_manifest():
man_path = BASE_DIR / "static" / "manifest.json"
return FileResponse(
man_path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
@@ -77,6 +207,7 @@ for r in (
reports.router,
voice.router,
settings.router,
auth_routes.router,
browser.router,
herman.router,
studio.router,
@@ -96,6 +227,8 @@ for r in (
agents_api_router,
marketing_api_router,
projects_api_router,
auth_routes.api_router,
auth_routes.users_api,
):
app.include_router(r)
@@ -103,6 +236,10 @@ for r in (
@app.on_event("startup")
def on_startup() -> None:
init_pool()
try:
auth_users.ensure_admin_seed()
except Exception as e:
print(f"auth seed warning: {e}")
@app.on_event("shutdown")
+131 -3
View File
@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import Request, APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import settings
@@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
@herman_api.post("/chat")
async def herman_chat_api(body: HermanChatBody):
async def herman_chat_api(request: Request, body: HermanChatBody):
if not body.message.strip():
raise HTTPException(400, "message required")
user = getattr(request.state, "user", None) or {}
result = await herman_service.chat(
body.message.strip(),
channel=(body.channel or "cockpit").strip(),
session_id=body.session_id,
confirm_action_id=body.confirm_action_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
)
return {"ok": True, **result}
@@ -2369,18 +2372,128 @@ class HermesSearchBody(BaseModel):
limit: int = Field(default=15, ge=1, le=30)
def _hermes_feed_base() -> str:
return (getattr(settings, "HERMES_FEED_URL", None) or "http://10.4.7.10:8755").rstrip("/")
@admin_router.get("/hermes/stats")
async def hermes_stats_proxy() -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/stats")
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/kpis")
async def hermes_kpis_proxy() -> dict[str, Any]:
"""Technical KPIs from Hermes Agent + Herman cockpit event counts."""
import httpx
from app.db import fetch_all
payload: dict[str, Any] = {
"online": False,
"synced_at": None,
"source": "hermes-agent",
"overview": {},
"buckets": [],
"tools_top": [],
"sources": [],
"herman_cockpit": [],
}
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{_hermes_feed_base()}/kpis")
if r.status_code == 200:
payload.update(r.json())
except Exception as exc:
payload["error"] = str(exc)[:160]
try:
rows = fetch_all(
"""
SELECT event_type, COUNT(*)::int AS count
FROM agent_events
GROUP BY event_type
ORDER BY COUNT(*) DESC
LIMIT 12
"""
)
payload["herman_cockpit"] = [
{"event_type": r.get("event_type") or "unknown", "count": int(r.get("count") or 0)}
for r in (rows or [])
]
except Exception:
payload["herman_cockpit"] = []
return payload
@admin_router.get("/hermes/bucket/{key}")
async def hermes_bucket_detail_proxy(key: str, limit: int = 60, tool: str | None = None) -> dict[str, Any]:
"""Bucket detail: recent tool events + touched files for explorer popup."""
import httpx
params: dict[str, Any] = {"limit": limit}
if tool:
params["tool"] = tool
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/bucket/{key}", params=params)
if r.status_code == 404:
return {"error": "unknown bucket", "key": key, "events": [], "files": []}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer")
async def hermes_explorer_proxy(path: str | None = None) -> dict[str, Any]:
"""Browse Hermes Agent filesystem (read-only, restricted roots)."""
import httpx
params = {}
if path:
params["path"] = path
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer", params=params)
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer/file")
async def hermes_explorer_file_proxy(path: str) -> dict[str, Any]:
"""Preview a file from Hermes Agent filesystem."""
import httpx
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer/file", params={"path": path})
if r.status_code in (403, 404, 400):
try:
err = (r.json() or {}).get("error") or r.text
except Exception:
err = r.text
return {"error": err, "path": path, "content": None}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/conversations")
async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
"""Live conversations from Hermes Agent (not June Postgres archive)."""
import httpx
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/conversations", params={"limit": limit})
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
r.raise_for_status()
@@ -2393,10 +2506,18 @@ async def hermes_feed_proxy(
limit: int = 80,
offset: int = 0,
) -> dict[str, Any]:
"""Live Telegram feed from Hermes Agent state.db."""
import httpx
params = {"limit": limit, "offset": offset}
params: dict[str, Any] = {"limit": limit, "offset": offset}
if chat_id is not None:
params["chat_id"] = chat_id
try:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/feed", params=params)
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
r.raise_for_status()
@@ -2469,6 +2590,13 @@ async def hermes_pa_slot_screenshot(label: str):
@admin_router.get("/hermes/users")
async def hermes_users_proxy() -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/users")
if r.status_code == 200 and (r.json().get("users") is not None):
return r.json()
except Exception:
pass
users: list[dict[str, Any]] = []
try:
async with httpx.AsyncClient(timeout=8.0) as client:
+12 -4
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
@@ -21,9 +21,9 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
active_tab = "souls"
active_tab = request.query_params.get("tab", "warroom")
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "warroom"
events: list = []
try:
events = fetch_all(
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
)
@router.get("/warroom/popup", response_class=HTMLResponse)
async def agents_warroom_popup(request: Request):
return templates.TemplateResponse(
"agents_warroom_popup.html",
{"request": request, "page_title": "War Room"},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
+339 -1
View File
@@ -5,7 +5,7 @@ import asyncio
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
@@ -545,3 +545,341 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req}
# --- War Room chat ---
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
reply_to_id: Optional[int] = None
@router.get("/warroom/rooms")
async def warroom_list_rooms() -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
return {"items": agent_chat.list_rooms()}
@router.post("/warroom/rooms/dm/{agent_key}")
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
from app.services import agent_chat
try:
room = agent_chat.get_or_create_dm(agent_key)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"room": room}
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids}
@router.post("/warroom/rooms/{room_id}/messages")
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
try:
result = await agent_chat.post_user_message(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.post("/warroom/rooms/{room_id}/messages/stream")
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
"""SSE stream: user → typing tokens → agent message(s) → done."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.post_user_message_stream(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/warroom/upload")
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
"""Upload a file to share in War Room (images/docs)."""
from pathlib import Path as P
import uuid
import re
raw_name = (file.filename or "upload.bin").strip()
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
ext = P(safe).suffix.lower()
allowed = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
}
if ext not in allowed:
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
data = await file.read()
if len(data) > 12 * 1024 * 1024:
raise HTTPException(400, "Max 12MB per bestand")
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
dest_dir.mkdir(parents=True, exist_ok=True)
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
(dest_dir / fname).write_bytes(data)
url = f"/static/uploads/warroom/{fname}"
return {
"ok": True,
"url": url,
"name": raw_name[:180],
"mime": file.content_type or "application/octet-stream",
"size": len(data),
}
@router.post("/warroom/digest")
async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]:
"""Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram."""
from app.services import warroom_digest
try:
return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
@router.post("/warroom/rooms/{room_id}/continue")
async def warroom_continue(request: Request, room_id: int, turns: int = 2):
"""Autonomous A2A continuation — agents keep debating with cockpit data."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.continue_warroom_stream(
room_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
max_turns=max(1, min(int(turns or 2), 4)),
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/warroom/ceo-inbox")
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
"""Direct messages from agents to CEO (@aissa)."""
from app.services import agent_chat
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
return {
"ok": True,
"items": items,
"unread": agent_chat.ceo_inbox_unread_count(),
}
class CeoInboxReadBody(BaseModel):
ids: Optional[list[int]] = None
all: bool = False
@router.post("/warroom/ceo-inbox/read")
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
from app.services import agent_chat
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
@router.delete("/warroom/rooms/{room_id}/messages")
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
"""Soft-delete all messages in a War Room / DM."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
n = agent_chat.clear_messages(room_id)
return {"ok": True, "cleared": n}
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
"""Soft-delete a War Room message."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
row = agent_chat.delete_message(room_id, message_id)
if not row:
raise HTTPException(404, "Bericht niet gevonden")
return {"ok": True, "id": message_id}
@router.post("/warroom/rooms/{room_id}/voice-room")
async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]:
"""Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS)."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
body = {}
try:
body = await request.json()
except Exception:
body = {}
action = str(body.get("action") or "join").lower()
agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"]
if not isinstance(agents, list):
agents = ["herman"]
agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12]
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
if action == "leave":
agent_chat.post_system_line(
f"🎙️ {who} verliet de Voice Room",
agent_key="herman",
metadata={"kind": "voice_room", "action": "leave", "opened_by": who},
)
return {"ok": True, "action": "leave"}
agent_chat.post_system_line(
f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents),
agent_key="herman",
metadata={
"kind": "voice_room",
"action": "join",
"opened_by": who,
"agents": agents,
},
)
return {"ok": True, "action": "join", "agents": agents, "host": who}
@router.post("/warroom/rooms/{room_id}/call")
async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]:
"""Open a simple no-account video room in an external HTTPS window.
Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab.
Default provider is Freifunk meet (no 8x8 login / no moderator gate).
"""
import os
from urllib.parse import quote
from app.db import execute, fetch_one
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/")
slug = f"FoodlinkkWR{room_id}"
if room.get("kind") == "dm" and room.get("agent_key"):
slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}"
url = f"{base}/{quote(slug)}"
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
recent = fetch_one(
"""SELECT id, metadata FROM warroom_messages
WHERE room_id = %s AND deleted_at IS NULL
AND metadata->>'kind' = 'call'
AND created_at > NOW() - INTERVAL '20 minutes'
ORDER BY id DESC LIMIT 1""",
(room_id,),
)
reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url)
if not reused:
execute(
"""UPDATE warroom_messages
SET deleted_at = NOW(), content = '',
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
AND (
metadata->>'kind' = 'call'
OR content ILIKE %s
OR content ILIKE %s
)""",
(room_id, "%War Room-call%", "%Call open%"),
)
agent_chat.post_system_line(
f"📹 Call open — geen account nodig: {url}",
agent_key="herman",
metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who},
)
return {
"ok": True,
"url": url,
"slug": slug,
"provider": "ffmuc",
"embed": False,
"mode": "external",
"reused": reused,
"hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.",
}
+34 -4
View File
@@ -1,17 +1,28 @@
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services.analytics_data import collect_analytics
from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings
router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class FindingIn(BaseModel):
kind: str = Field(default="insight", description="win|opportunity|attention|insight")
title: str
body: str = ""
score: float | None = None
source: str = "hermes"
href: str | None = None
meta: dict[str, Any] | None = None
@router.get("")
async def analytics_page(request: Request):
initial = collect_analytics({})
@@ -30,5 +41,24 @@ async def analytics_api(
days: int = Query(90, ge=7, le=365),
):
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
return JSONResponse(collect_analytics(filters))
@router.get("/api/findings")
async def analytics_findings(limit: int = Query(20, ge=1, le=100)):
return {"items": list_hermes_findings(limit)}
@router.post("/api/findings")
async def analytics_findings_create(payload: FindingIn = Body(...)):
"""Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard."""
row = add_finding(
kind=payload.kind,
title=payload.title,
body=payload.body,
score=payload.score,
source=payload.source,
href=payload.href,
meta=payload.meta,
)
return {"ok": True, "finding": row}
+231
View File
@@ -0,0 +1,231 @@
"""Login / logout / me + user admin API."""
from __future__ import annotations
from pathlib import Path
import uuid
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app import auth_users
router = APIRouter(tags=["auth"])
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
raw_name = (file.filename or "avatar.jpg").lower()
ext = Path(raw_name).suffix
if ext not in ALLOWED_AVATAR:
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
data = await file.read()
if len(data) > 3 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Max 3 MB")
# strip old files for this user
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
path = AVATAR_DIR / fname
path.write_bytes(data)
return f"/static/uploads/avatars/{fname}"
class LoginBody(BaseModel):
username: str
password: str
class UserCreateBody(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
permissions: dict[str, Any] = Field(default_factory=dict)
class UserUpdateBody(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
permissions: Optional[dict[str, Any]] = None
def _session_user(request: Request) -> dict[str, Any] | None:
return getattr(request.state, "user", None)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
if _session_user(request):
return RedirectResponse(next or "/", status_code=303)
return templates.TemplateResponse(
"login.html",
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
)
@router.post("/login")
async def login_submit(request: Request):
form = await request.form()
username = str(form.get("username") or "").strip()
password = str(form.get("password") or "")
next_url = str(form.get("next") or "/")
if not next_url.startswith("/"):
next_url = "/"
user = auth_users.authenticate(username, password)
if not user:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"page_title": "Inloggen",
"next": next_url,
"error": "Ongeldige gebruikersnaam of wachtwoord",
"username": username,
},
status_code=401,
)
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return RedirectResponse(next_url, status_code=303)
@router.post("/logout")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@api_router.post("/login")
async def api_login(request: Request, body: LoginBody):
user = auth_users.authenticate(body.username, body.password)
if not user:
raise HTTPException(status_code=401, detail="Ongeldige login")
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return {"ok": True, "user": user}
@api_router.post("/logout")
async def api_logout(request: Request):
request.session.clear()
return {"ok": True}
@api_router.get("/me")
async def api_me(request: Request):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
return {
"user": user,
"pages": auth_users.PAGE_CATALOG,
"can_manage_users": auth_users.can_manage_users(user),
}
def _require_user_admin(request: Request) -> dict[str, Any]:
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
if not auth_users.can_manage_users(user):
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
return user
@users_api.get("")
async def users_list(request: Request):
_require_user_admin(request)
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
@users_api.post("")
async def users_create(request: Request, body: UserCreateBody):
_require_user_admin(request)
try:
user = auth_users.create_user(
body.username, body.password, body.display_name, body.role, body.permissions or {}
)
except Exception as e:
msg = str(e)
if "unique" in msg.lower() or "duplicate" in msg.lower():
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
raise HTTPException(status_code=400, detail=msg) from e
return {"user": user}
@users_api.put("/{user_id}")
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
_require_user_admin(request)
try:
user = auth_users.update_user(
user_id,
display_name=body.display_name,
role=body.role,
is_active=body.is_active,
permissions=body.permissions,
password=body.password or None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"user": user}
@users_api.delete("/{user_id}")
async def users_delete(request: Request, user_id: int):
actor = _require_user_admin(request)
try:
auth_users.delete_user(user_id, actor_id=actor["id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True}
@api_router.post("/me/avatar")
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
url = await _save_avatar_file(user["id"], file)
updated = auth_users.set_avatar(user["id"], url)
request.session["username"] = updated["username"]
return {"user": updated, "avatar_url": url}
@users_api.post("/{user_id}/avatar")
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
_require_user_admin(request)
url = await _save_avatar_file(user_id, file)
updated = auth_users.set_avatar(user_id, url)
return {"user": updated, "avatar_url": url}
@users_api.delete("/{user_id}/avatar")
async def delete_user_avatar(request: Request, user_id: int):
actor = _require_user_admin(request)
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
updated = auth_users.set_avatar(user_id, None)
return {"user": updated}
+5
View File
@@ -225,3 +225,8 @@ async def cto_dashboard_legacy(request: Request):
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
@router.get("/trust")
async def trust_page(request: Request):
return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"})
+39 -1
View File
@@ -276,6 +276,44 @@ async def api_tenders(country: Optional[str] = None, status: Optional[str] = "op
return await _proxy("GET", "/tenders", params=params)
@router.get("/api/export-intel/settings")
async def api_export_intel_settings_get():
return await _proxy("GET", "/settings")
@router.put("/api/export-intel/settings")
async def api_export_intel_settings_put(request: Request):
body = await request.json()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.put(f"{TOOLS}/export-intel/settings", json=body)
return r.json()
@router.post("/api/export-intel/sync/places")
async def api_sync_places(request: Request):
body = {}
if request.headers.get("content-type", "").startswith("application/json"):
try:
body = await request.json()
except Exception:
body = {}
async with httpx.AsyncClient(timeout=7200.0) as client:
r = await client.post(f"{TOOLS}/export-intel/sync/places", json=body)
return r.json()
@router.post("/api/export-intel/sync/cancel")
async def api_sync_cancel():
return await _proxy("POST", "/sync/cancel")
@router.get("/api/export-intel/sync/status")
async def api_sync_status():
return await _proxy("GET", "/sync/status")
@router.post("/api/export-intel/sync/{kind}")
async def api_sync(kind: str, request: Request):
body = {}
@@ -284,7 +322,7 @@ async def api_sync(kind: str, request: Request):
body = await request.json()
except Exception:
body = {}
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region"):
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region", "missing", "places"):
return {"error": "unknown sync kind"}
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
async with httpx.AsyncClient(timeout=timeout) as client:
+13 -2
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app import auth_users
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -10,12 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social", "ai")
allowed = ("email", "general", "themes", "permissions", "social", "users")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
user = getattr(request.state, "user", None)
if active == "users" and not auth_users.can_manage_users(user):
return RedirectResponse("/settings?tab=email", status_code=303)
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": tab if tab in allowed else "email",
"active_tab": active,
"can_manage_users": auth_users.can_manage_users(user),
"current_user": user,
},
)
+2 -1
View File
@@ -406,6 +406,7 @@ def llm_presets() -> dict[str, Any]:
@settings_router.get("/llm")
def llm_list() -> dict[str, Any]:
llm_router.ensure_openrouter_placeholder()
items = llm_router.list_providers()
default = next((i for i in items if i.get("is_default")), items[0] if items else None)
return {"providers": items, "default": default, "presets": llm_router.list_presets()}
@@ -415,7 +416,7 @@ def llm_list() -> dict[str, Any]:
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]
model = body.model or preset.get("default_model") or (preset.get("models") or ["deepseek-chat"])[0]
if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one(
File diff suppressed because it is too large Load Diff
@@ -63,6 +63,11 @@ def create_handoff(
"handoff_type": handoff_type,
}
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
try:
from app.services import agent_chat
agent_chat.announce_handoff(src, dst, handoff_type, correlation_id=cid)
except Exception:
pass
return handoff
+18
View File
@@ -9,9 +9,15 @@ AGENT_NAME_MAP: dict[str, str] = {
"rss_feeds": "marketing",
"wholesale_scraper": "sourcing",
"halal_registry": "halal",
"halal_engine": "halal",
"halal_reco": "halal",
"branch_scraper": "sourcing",
"hermes": "herman",
"herman_delegate": "herman",
"ops": "sysops",
"sysop": "sysops",
"web_builder": "webbuilder",
"packaging_agent": "packaging",
}
@@ -20,3 +26,15 @@ def normalize_agent_key(name: str | None) -> str:
if not key:
return ""
return AGENT_NAME_MAP.get(key, key)
def aliases_for_key(canonical: str) -> list[str]:
"""All raw agent_name values that map to this soul key (including the key itself)."""
key = (canonical or "").strip().lower()
if not key:
return []
names = {key}
for raw, canon in AGENT_NAME_MAP.items():
if canon == key:
names.add(raw)
return sorted(names)
+139
View File
@@ -0,0 +1,139 @@
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
def slugify(name: str, max_len: int = 40) -> str:
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
return (s[:max_len] or "file")
def root() -> Path:
return AGENTS_NAS_ROOT
def unc() -> str:
return AGENTS_NAS_UNC
def smb() -> str:
return AGENTS_NAS_SMB
def ensure_root() -> Path:
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
sys_dir = AGENTS_NAS_ROOT / "_system"
sys_dir.mkdir(parents=True, exist_ok=True)
readme = sys_dir / "README.txt"
if not readme.exists():
readme.write_text(
"Foodlinkk Agents NAS workspace\n"
f"Pad: {AGENTS_NAS_SMB}\n"
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
"Submappen: notes, exports, memory, inbox, outbox\n",
encoding="utf-8",
)
meta = {
"unc": AGENTS_NAS_UNC,
"smb": AGENTS_NAS_SMB,
"container_path": str(AGENTS_NAS_ROOT),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
(sys_dir / "workspace.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
)
return AGENTS_NAS_ROOT
def ensure_agent_folder(agent_key: str) -> Path:
ensure_root()
key = slugify(agent_key or "unknown", 32).lower()
base = AGENTS_NAS_ROOT / key
for sub in AGENT_SUBDIRS:
(base / sub).mkdir(parents=True, exist_ok=True)
meta = {
"agent_key": key,
"path": str(base),
"smb": f"{AGENTS_NAS_SMB}/{key}",
"subdirs": list(AGENT_SUBDIRS),
}
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
return base
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
ensure_root()
keys = agent_keys or []
created = []
for k in keys:
p = ensure_agent_folder(str(k))
created.append(str(p))
return {
"ok": True,
"root": str(AGENTS_NAS_ROOT),
"smb": AGENTS_NAS_SMB,
"agents": created,
"count": len(created),
}
def memory_rule_text() -> str:
return (
"NAS DATA (HARDCODED — VERPLICHT):\n"
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
"inbox/ (inkomend), outbox/ (uitgaand).\n"
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
)
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
base = ensure_agent_folder(agent_key)
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
rel = relative_path.lstrip("/\\").replace("..", "")
if not rel:
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
target = (base / sub / rel).resolve()
if not str(target).startswith(str(base.resolve())):
raise ValueError("Pad buiten agent-map niet toegestaan")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
key = slugify(agent_key).lower()
return {
"ok": True,
"path": str(target),
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
}
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
return write_text(
agent_key,
name,
json.dumps(data, indent=2, ensure_ascii=False, default=str),
subdir=subdir,
)
def agent_paths(agent_key: str) -> dict[str, str]:
key = slugify(agent_key or "unknown", 32).lower()
return {
"root": str(AGENTS_NAS_ROOT / key),
"smb": f"{AGENTS_NAS_SMB}/{key}",
}
+142 -22
View File
@@ -1,49 +1,169 @@
"""Agent soul profiles and Herman permissions."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services.agent_names import aliases_for_key, normalize_agent_key
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
if events_6h > 0 and errors_24h == 0:
return "healthy"
if events_6h > 0:
return "warn"
if event_count > 0:
return "idle"
return "offline"
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
rows = fetch_all(
"""
SELECT agent_name, title, status, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '30 days'
ORDER BY created_at DESC
"""
)
now = datetime.now(timezone.utc)
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
key = normalize_agent_key(str(r.get("agent_name") or ""))
if not key:
continue
bucket = by_key.setdefault(
key,
{
"event_count": 0,
"events_6h": 0,
"events_24h": 0,
"errors_24h": 0,
"last_event_at": None,
"last_event_title": None,
"current_status": None,
"current_event_type": None,
},
)
bucket["event_count"] += 1
created = r.get("created_at")
if bucket["last_event_at"] is None:
bucket["last_event_at"] = created
bucket["last_event_title"] = r.get("title")
bucket["current_status"] = r.get("status")
if created is not None:
if hasattr(created, "tzinfo") and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age_ok = True
try:
if created >= now - timedelta(hours=6):
bucket["events_6h"] += 1
if created >= now - timedelta(hours=24):
bucket["events_24h"] += 1
if str(r.get("status") or "") in ("error", "rejected"):
bucket["errors_24h"] += 1
except Exception:
age_ok = False
_ = age_ok
# Also count older totals beyond 30d window lightly
try:
totals = fetch_all(
"""
SELECT agent_name, COUNT(*)::int AS c
FROM agent_events
GROUP BY agent_name
"""
)
total_by: dict[str, int] = {}
for r in totals:
k = normalize_agent_key(str(r.get("agent_name") or ""))
if not k:
continue
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
for k, c in total_by.items():
bucket = by_key.setdefault(
k,
{
"event_count": 0,
"events_6h": 0,
"events_24h": 0,
"errors_24h": 0,
"last_event_at": None,
"last_event_title": None,
"current_status": None,
"current_event_type": None,
},
)
bucket["event_count"] = c
except Exception:
pass
return by_key
def list_souls() -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_task,
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_status,
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_event_type
FROM agent_souls s ORDER BY s.display_name"""
)
return [dict(r) for r in rows]
rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name")
stats = _aggregate_event_stats()
out: list[dict[str, Any]] = []
for r in rows:
soul = dict(r)
key = str(soul.get("agent_key") or "").lower()
st = stats.get(key, {})
events_6h = int(st.get("events_6h") or 0)
errors_24h = int(st.get("errors_24h") or 0)
event_count = int(st.get("event_count") or 0)
health = _node_health(events_6h, errors_24h, event_count)
lat = st.get("last_event_at")
if lat is not None and hasattr(lat, "isoformat"):
lat = lat.isoformat()
soul["event_count"] = event_count
soul["events_6h"] = events_6h
soul["events_24h"] = int(st.get("events_24h") or 0)
soul["errors_24h"] = errors_24h
soul["health"] = health
soul["last_event_at"] = lat
soul["current_task"] = st.get("last_event_title") or soul.get("current_task")
soul["current_status"] = st.get("current_status")
# Working = recent activity (not just DB is_active flag)
soul["working"] = events_6h > 0
# is_active souls are never "offline" in the registry (War Room / mesh ready)
if not soul.get("is_active", True):
soul["activity"] = "offline"
else:
soul["activity"] = (
"busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5)
else ("active" if events_6h > 0 else "idle")
)
out.append(soul)
return out
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count
FROM agent_souls s WHERE agent_key = %s""",
(agent_key.lower(),),
)
row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),))
if not row:
return None
out = dict(row)
key = agent_key.lower()
stats = _aggregate_event_stats().get(key, {})
out["event_count"] = int(stats.get("event_count") or 0)
out["events_6h"] = int(stats.get("events_6h") or 0)
out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"])
out["recent_events"] = list_agent_events(agent_key, limit=15)
return out
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
names = aliases_for_key(agent_key)
if not names:
return []
placeholders = ",".join(["%s"] * len(names))
rows = fetch_all(
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
FROM agent_events
WHERE LOWER(agent_name) = %s
WHERE LOWER(agent_name) IN ({placeholders})
ORDER BY created_at DESC
LIMIT %s""",
(agent_key.lower(), limit),
(*[n.lower() for n in names], limit),
)
out: list[dict[str, Any]] = []
for row in rows:
+26 -1
View File
@@ -8,7 +8,7 @@ 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 import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent
from app.services.agent_events_log import log_agent_event
from app.services.agent_names import normalize_agent_key
@@ -24,6 +24,8 @@ GLOBAL_HELP = [
"handoff <agent> <type> [notitie]",
"say <bericht> — log een notitie",
"ask <vraag> — AI-antwoord in rol van deze agent",
"nas — toon Agents NAS-pad",
"save <bestand> <tekst> — schrijf naar NAS Agents/<agent>/notes/",
]
AGENT_HELP: dict[str, list[str]] = {
@@ -430,6 +432,29 @@ async def execute_terminal_command(
else:
note = " ".join(args[2:]) if len(args) > 2 else ""
lines = _handoff(key, args[0], args[1], note)
elif cmd == "nas":
try:
agent_nas.ensure_agent_folder(key)
paths = agent_nas.agent_paths(key)
lines = [
_line("Agents NAS: " + str(agent_nas.smb())),
_line("Jouw map: " + str(paths.get("smb") or "")),
_line("Container: " + str(paths.get("root") or "")),
_line("Sub: notes/ exports/ memory/ inbox/ outbox/"),
]
except Exception as exc:
lines = [_line("NAS fout: " + str(exc), line_type="error")]
elif cmd == "save":
if len(args) < 2:
lines = [_line("Gebruik: save <bestandsnaam> <tekst>", line_type="error")]
else:
fname = args[0]
body = " ".join(args[1:])
try:
result = agent_nas.write_text(key, fname, body, subdir="notes")
lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))]
except Exception as exc:
lines = [_line("Save mislukt: " + str(exc), line_type="error")]
elif cmd == "say":
if not rest:
lines = [_line("Gebruik: say <bericht>", line_type="error")]
+382 -27
View File
@@ -1,10 +1,10 @@
"""Comprehensive analytics data from all DB tables with optional filters."""
"""Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all, fetch_one
from app.db import execute, fetch_all, fetch_one
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
return rows
def ensure_findings_table() -> None:
try:
execute(
"""
CREATE TABLE IF NOT EXISTS analytics_findings (
id SERIAL PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'insight',
title TEXT NOT NULL,
body TEXT,
score DOUBLE PRECISION,
source TEXT DEFAULT 'hermes',
href TEXT,
meta JSONB DEFAULT '{}'::jsonb,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
"""
)
except Exception:
pass
def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]:
ensure_findings_table()
try:
rows = fetch_all(
"""
SELECT id, kind, title, body, score, source, href, meta, created_at
FROM analytics_findings
WHERE active = TRUE
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return _iso_rows(rows or [])
except Exception:
return []
def add_finding(
*,
kind: str,
title: str,
body: str = "",
score: float | None = None,
source: str = "hermes",
href: str | None = None,
meta: dict | None = None,
) -> dict[str, Any]:
ensure_findings_table()
import json
row = fetch_one(
"""
INSERT INTO analytics_findings (kind, title, body, score, source, href, meta)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, kind, title, body, score, source, href, meta, created_at
""",
(
kind or "insight",
title,
body or "",
score,
source or "hermes",
href,
json.dumps(meta or {}),
),
)
return _iso_rows([row])[0] if row else {}
def _revenue_kpis() -> dict[str, Any]:
out = {
"active_projects": 0,
"margin_month": 0.0,
"margin_year": 0.0,
"vision_text": "",
"horizon_text": "",
"tagline": "",
}
try:
row = fetch_one(
"""
SELECT COUNT(*)::int AS c,
COALESCE(SUM(margin_month),0) AS mm,
COALESCE(SUM(margin_year),0) AS my
FROM revenue_projects
WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done')
"""
)
if row:
out["active_projects"] = int(row.get("c") or 0)
out["margin_month"] = float(row.get("mm") or 0)
out["margin_year"] = float(row.get("my") or 0)
except Exception:
pass
try:
g = fetch_one(
"""
SELECT vision_text, horizon_text, tagline, mid_text
FROM revenue_cockpit_goals
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
"""
)
if g:
out["vision_text"] = g.get("vision_text") or ""
out["horizon_text"] = g.get("horizon_text") or ""
out["tagline"] = g.get("tagline") or ""
out["mid_text"] = g.get("mid_text") or ""
except Exception:
pass
return out
def _build_pulse(data: dict[str, Any]) -> dict[str, Any]:
k = data.get("kpis") or {}
partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])}
active_p = int(partners.get("active") or k.get("crm_partnerships") or 0)
none_p = int(partners.get("none") or 0)
proposal_p = int(partners.get("proposal") or 0)
stores = int(k.get("supermarkets") or 0)
cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0
gap = max(stores - active_p - proposal_p, 0)
wins: list[dict[str, Any]] = []
opportunities: list[dict[str, Any]] = []
attention: list[dict[str, Any]] = []
if stores >= 1000:
wins.append({
"id": "win-coverage",
"title": "Retail footprint is massief",
"body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."),
"score": 92,
"metric": f"{stores} stores",
"href": "/retail",
})
if active_p >= 10:
wins.append({
"id": "win-crm",
"title": "Actieve partnerships draaien",
"body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.",
"score": 78,
"metric": f"{active_p} actief",
"href": "/retail",
})
if int(k.get("rss_items") or 0) >= 200:
wins.append({
"id": "win-rss",
"title": "Markt-signalen stromen binnen",
"body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.",
"score": 74,
"metric": f"{int(k['rss_items'])} items",
"href": "/marketing",
})
if int(k.get("promo_campaigns") or 0) >= 10:
wins.append({
"id": "win-promo",
"title": "Promo-intelligence actief",
"body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.",
"score": 70,
"metric": f"{int(k['promo_campaigns'])} campagnes",
"href": "/marketing",
})
if float(k.get("margin_month") or 0) > 0:
wins.append({
"id": "win-margin",
"title": "Revenue engine levert marge",
"body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."),
"score": 88,
"metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."),
"href": "/revenue-cockpit",
})
if int(k.get("wholesalers") or 0) >= 100:
wins.append({
"id": "win-wholesale",
"title": "Groothandel-netwerk in kaart",
"body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.",
"score": 68,
"metric": f"{int(k['wholesalers'])} GH",
"href": "/retail",
})
if int(k.get("agent_events") or 0) >= 500:
wins.append({
"id": "win-agents",
"title": "Agent-mesh is productief",
"body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.",
"score": 66,
"metric": f"{int(k['agent_events'])} events",
"href": "/agents",
})
if gap >= 100:
opportunities.append({
"id": "opp-white-space",
"title": "Enorm witruimte in retail CRM",
"body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."),
"score": min(98, 40 + int(gap / 100)),
"metric": f"{gap} open",
"href": "/retail",
"tag": "distributie",
})
if cover_pct < 5:
opportunities.append({
"id": "opp-cover",
"title": "Partnership-dekking is nog dun",
"body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.",
"score": 90,
"metric": f"{cover_pct}% dekking",
"href": "/retail",
"tag": "groei",
})
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
opportunities.append({
"id": "opp-clients",
"title": "Klanten in intake → nog niet actief",
"body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.",
"score": 82,
"metric": "0 active",
"href": "/clients",
"tag": "crm",
})
if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0:
opportunities.append({
"id": "opp-pipeline",
"title": "Deals zonder pipeline-waarde",
"body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.",
"score": 76,
"metric": "€0 pipeline",
"href": "/deals",
"tag": "sales",
})
top_opp = data.get("top_opportunities") or []
if top_opp:
best = top_opp[0]
opportunities.append({
"id": "opp-halal-top",
"title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}",
"body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.",
"score": round(float(best.get("halal_opportunity_score") or 0)),
"metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}",
"href": "/retail",
"tag": "halal",
})
# cluster cities
cities: dict[str, int] = {}
for o in top_opp:
cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1
top_city = sorted(cities.items(), key=lambda x: -x[1])[0]
if top_city[1] >= 2:
opportunities.append({
"id": "opp-city-cluster",
"title": f"Cluster-kans in {top_city[0]}",
"body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.",
"score": 72,
"metric": top_city[0],
"href": "/retail",
"tag": "geo",
})
if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0:
opportunities.append({
"id": "opp-gh-contacts",
"title": "Groothandel-contacten ontbreken",
"body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.",
"score": 69,
"metric": "0 GH-contacten",
"href": "/retail",
"tag": "netwerk",
})
if int(k.get("pending_approvals") or 0) > 0:
attention.append({
"id": "att-approvals",
"title": "Goedkeuringen wachten",
"body": f"{int(k['pending_approvals'])} agent-items op needs_approval.",
"score": 55,
"metric": str(int(k["pending_approvals"])),
"href": "/agents",
})
if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100:
attention.append({
"id": "att-bookmarks",
"title": "Geen RSS-bookmarks",
"body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.",
"score": 48,
"metric": "0 bookmarks",
"href": "/marketing",
})
# Pulse score 0-100
score = 35
score += min(18, active_p) # up to +18
score += 12 if stores >= 2000 else (6 if stores >= 500 else 0)
score += 8 if int(k.get("rss_items") or 0) >= 500 else 0
score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0)
score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0
score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0
if cover_pct < 1:
score -= 12
elif cover_pct < 3:
score -= 6
if int(k.get("pending_approvals") or 0) >= 5:
score -= 5
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
score -= 4
score = max(0, min(100, score))
if score >= 75:
label, tone = "Sterk momentum", "good"
elif score >= 55:
label, tone = "Stabiel · kansen open", "mid"
else:
label, tone = "Opbouw-fase · focus nodig", "low"
wins.sort(key=lambda x: -int(x.get("score") or 0))
opportunities.sort(key=lambda x: -int(x.get("score") or 0))
return {
"score": score,
"label": label,
"tone": tone,
"cover_pct": cover_pct,
"white_space": gap,
"wins": wins[:8],
"opportunities": opportunities[:8],
"attention": attention[:6],
"headline": "Wat goed gaat vs waar de kansen liggen",
"sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.",
}
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {}
chain = f.get("chain") or None
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"filters": f,
}
rev = _revenue_kpis()
data["kpis"] = {
"clients_total": _safe_count("clients"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"deals_total": _safe_count("deals"),
"pipeline_eur": float(
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
or 0
),
"supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"),
@@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"),
"active_projects": rev.get("active_projects") or 0,
"margin_month": rev.get("margin_month") or 0,
"margin_year": rev.get("margin_year") or 0,
}
data["goals"] = {
"vision_text": rev.get("vision_text") or "",
"horizon_text": rev.get("horizon_text") or "",
"tagline": rev.get("tagline") or "",
"mid_text": rev.get("mid_text") or "",
}
try:
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_by_agent"] = fetch_all(
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
(days,),
)
except Exception:
data["events_by_agent"] = []
@@ -100,11 +449,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["supermarkets_by_chain"] = []
try:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
if store_where
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
if store_where:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12",
tuple(store_params),
)
else:
data["supermarkets_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["supermarkets_by_province"] = []
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_timeline"] = fetch_all(
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY DATE(created_at) ORDER BY day ASC""",
(days,),
)
except Exception:
data["events_timeline"] = []
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["top_opportunities"] = fetch_all(
"""SELECT s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 10"""
ORDER BY ros.halal_opportunity_score DESC LIMIT 12"""
)
except Exception:
data["top_opportunities"] = []
@@ -191,25 +545,23 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
except Exception:
data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
if agent:
agent_where += " AND agent_name = %s"
try:
try:
if agent:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""",
(agent,),
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
ORDER BY created_at DESC LIMIT 25""",
(days, agent),
)
except Exception:
data["recent_events"] = []
else:
try:
else:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25"""
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s)
ORDER BY created_at DESC LIMIT 25""",
(days,),
)
except Exception:
data["recent_events"] = []
except Exception:
data["recent_events"] = []
try:
data["filter_meta"] = {
@@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
):
if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key])
data["pulse"] = _build_pulse(data)
data["hermes_findings"] = list_hermes_findings(20)
return data
+238 -252
View File
@@ -243,10 +243,13 @@ def _collect_retail_intel(data: dict[str, Any]) -> None:
try:
data["top_opportunities"] = fetch_all(
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
"""SELECT s.name, s.chain, s.city, s.address, s.postcode, ros.halal_opportunity_score
FROM retail_opportunity_scores ros
JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
WHERE s.name NOT ILIKE '%masala%'
AND s.name NOT ILIKE 'TEST%'
AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%'
ORDER BY ros.halal_opportunity_score DESC LIMIT 8"""
)
except Exception:
data["top_opportunities"] = []
@@ -467,126 +470,77 @@ async def stream_daily_briefing():
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
yield _briefing_step("crm", "CRM", "crm", "running", "Pipeline & klanten ophalen…")
_collect_crm_core(data)
yield _briefing_step(
"crm", "PostgreSQL", "crm", "ok",
"crm", "CRM", "crm", "ok",
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
"tables: clients, deals, products, suppliers",
)
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
yield _briefing_step("agents", "Herman", "herman", "running", "Open beslissingen filteren…")
_collect_agent_queue(data)
data["pending_approval_requests"] = _fresh_approvals(data.get("pending_approval_requests") or [])
data["pending_approvals"] = len(data["pending_approval_requests"])
pending = data.get("pending_approval_requests") or []
yield _briefing_step(
"agents", "PostgreSQL", "herman", "ok",
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
"table: agent_action_requests",
"agents", "Herman", "herman", "ok",
f"{len(pending)} relevante beslissingen",
)
for row in pending[:4]:
agent = row.get("agent_key") or "agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
)
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
yield _briefing_step("projects", "Operations", "product", "running", "Milestones & projectstatus…")
_collect_projects_ops(data)
yield _briefing_step(
"projects", "PostgreSQL", "sysops", "ok",
f"{len(data.get('project_assets_recent') or [])} project assets · "
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
"tables: project_assets, sysops_activity, config_backups",
"projects", "Operations", "product", "ok",
f"{len(data.get('milestones_pending') or [])} open milestones",
)
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
yield _briefing_step("pipeline", "Finance", "finance", "running", "Pipeline stages laden…")
_collect_pipeline_events(data)
stages = len(data.get("deals_by_stage") or [])
events = data.get("recent_events") or []
yield _briefing_step(
"pipeline", "PostgreSQL", "finance", "ok",
f"{stages} pipeline stages · {len(events)} recente agent-events",
"tables: deals, agent_events",
"pipeline", "Finance", "finance", "ok",
f"{stages} pipeline stages",
)
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
# NAS collected for internal stats but not streamed as CEO noise
_collect_nas_analytics(data)
yield _briefing_step(
"nas", "NAS analytics", "knowledge", "ok",
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
"tables: document_analytics, document_word_counts",
)
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
yield _briefing_step("retail", "Retail", "retail", "running", "Supermarkten & partnerships…")
_collect_retail_intel(data)
opp = data.get("top_opportunities") or []
yield _briefing_step(
"retail", "Retail 360", "retail", "ok",
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} open milestones",
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
"retail", "Retail", "retail", "ok",
f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} milestones",
)
if opp:
top = opp[0]
yield _briefing_step(
"agent_msg", "agent_mesh", "retail", "agent",
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
)
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
yield _briefing_step("rss", "Markt", "marketing", "running", "Food & retail nieuws selecteren…")
_collect_rss_market(data)
trend_n = len(data.get("trending_food") or [])
yield _briefing_step(
"rss", "RSS feeds", "marketing", "ok",
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
"tables: rss_items, rss_feeds, promo_campaigns",
)
yield _briefing_step(
"agent_msg", "agent_mesh", "marketing", "agent",
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
"rss", "Markt", "marketing", "ok",
f"{trend_n} relevante food-headlines",
)
data["activity_log"] = _build_activity_log(data)
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
seen: set[str] = set()
mesh_events = data.get("recent_events") or []
for ev in mesh_events[:10]:
agent = (ev.get("agent_name") or "agent").lower()
if agent in seen:
continue
seen.add(agent)
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
)
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
yield _briefing_step(
"agent_msg", "agent_mesh", "herman", "agent",
f"Herman → {agent_key}: briefing context gedeeld",
)
model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent"
yield _briefing_step(
"agent_mesh", "Agent mesh", "herman", "ok",
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
)
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
"Herman schrijft executive samenvatting met AI…",
"ai", f"Herman ({model_label})", "herman", "running",
"Herman schrijft CEO-briefing…",
)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
except (asyncio.TimeoutError, Exception) as exc:
ai_part = ""
yield _briefing_step(
"ai", "Ollama", "herman", "warn",
"AI timeout — gebruik template samenvatting",
"ai", "Herman", "herman", "warn",
"AI timeout — template samenvatting",
str(exc)[:120],
)
else:
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
"ai", f"Herman ({model_label})", "herman", "ok",
f"Briefing klaar ({len(ai_part or '')} tekens)",
)
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
@@ -607,6 +561,34 @@ async def stream_daily_briefing():
}
def _fresh_approvals(rows: list | None, *, max_age_days: int = 14) -> list:
"""Drop stale agent approvals so SysOps noise does not dominate the CEO digest."""
from datetime import datetime, timezone, timedelta
out = []
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
for row in rows or []:
ts = row.get("created_at") or row.get("requested_at") or row.get("updated_at")
if ts is None:
continue
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
try:
if ts < cutoff:
continue
except TypeError:
continue
agent = (row.get("agent_key") or "").lower()
action = (row.get("action_type") or "").lower()
# Keep business agents; skip routine SysOps unless critical keyword
if agent in ("sysops", "ops", "it") or action in ("maintenance_scan", "config_backup", "gitea_sync"):
title = (row.get("title") or "").lower()
if not any(k in title for k in ("critical", "kritiek", "down", "security", "breach")):
continue
out.append(row)
return out
def _build_activity_log(data: dict[str, Any]) -> list[str]:
lines: list[str] = []
for row in data.get("pending_approval_requests") or []:
@@ -698,151 +680,161 @@ def _revenue_line(p: dict[str, Any]) -> str:
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""Gestructureerde live samenvatting — Cucina B2C + Foodlinkk B2B + cockpit."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
pending_n = int(data.get("pending_approvals") or 0)
"""CEO digest: B2B + B2C + focus + trends + nieuws — geen SysOps."""
revenue = data.get("revenue") or {}
rev_stats = revenue.get("stats") or {}
rev_goals = rev_stats.get("goals") or {}
export = data.get("export_intel") or {}
market = data.get("market_summary") or {}
trends = data.get("market_trends") or []
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
sections: list[dict[str, Any]] = []
# —— Foodlinkk B2B ——
# —— Foodlinkk B2B (compact) ——
fl_lines: list[str] = [
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
f"Pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
]
if export:
if export.get("tenders_open") or export.get("distributors"):
fl_lines.append(
f"Wereldexport: {export.get('distributors', 0):,} distributeurs · "
f"{export.get('entities', 0):,} entiteiten · {export.get('tenders_open', 0)} open tenders · "
f"fase {export.get('phase', '—')}"
f"Export: {export.get('distributors', 0):,} distributeurs · {export.get('tenders_open', 0)} open tenders"
)
if export.get("crm_linked"):
fl_lines.append(f"Export → CRM gekoppeld: {export.get('crm_linked')} records")
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
fl_projects = sorted(
fl_projects,
key=lambda p: (
1 if "verkopen" in (p.get("name") or "").lower() else 0,
-float(p.get("margin_month") or 0),
),
[p for p in fl_projects if p.get("margin_month")],
key=lambda p: -float(p.get("margin_month") or 0),
)
if fl_projects:
fl_lines.append("Revenue projecten: " + "; ".join(_revenue_line(p) for p in fl_projects[:3]))
if rev_goals.get("vision_text"):
vision = str(rev_goals["vision_text"]).strip().split("\n")[0][:90]
fl_lines.append(f"Visie: {vision}")
sections.append({"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines})
# —— Cucina B2C ——
cu_lines: list[str] = [
f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships",
f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items",
]
if rev_stats:
cu_lines.insert(
0,
f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · "
f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen",
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
if cu_projects:
cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4]))
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
if halal_items:
cu_lines.append("🎯 Halal kansen (live engine):")
for rec in halal_items[:4]:
reason = (rec.get("reasons") or [""])[0]
cu_lines.append(
f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')} — "
f"score {rec.get('score')} · {reason}"
)
elif opp:
top = opp[0]
cu_lines.append(
f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — "
f"score {round(float(top.get('halal_opportunity_score') or 0))}/100"
)
ms = data.get("milestones_pending") or []
if ms:
cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})")
sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines})
# —— Vandaag in cockpit ——
cockpit_lines: list[str] = []
if pending_n:
cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou")
for row in (data.get("pending_approval_requests") or [])[:3]:
cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}")
for line in activity[:5]:
cockpit_lines.append(_short_activity(line))
if not cockpit_lines:
cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.")
sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "⚡", "lines": cockpit_lines[:6]})
# —— Markt & kennis ——
news_lines: list[str] = []
if market.get("best_performer"):
bp = market["best_performer"]
news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
seen_news: set[str] = set()
for row in (data.get("trending_food") or []):
title = str(row.get("title") or "").strip()[:95]
if not title or title in seen_news:
n_rev = 0
for p in fl_projects:
name_l = (p.get("name") or "").lower()
if any(x in name_l for x in ("verkopen", "2030", "total earnings", "total expense", "loonkosten", "boekhouder", "linknbit")):
continue
seen_news.add(title)
news_lines.append(title)
if len(seen_news) >= 2:
fl_lines.append(_revenue_line(p))
n_rev += 1
if n_rev >= 2:
break
if data.get("nas_docs"):
news_lines.append(
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
)
if news_lines:
sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]})
if export.get("crm_linked"):
fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld")
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = (row.get("title") or row.get("action_type") or "goedkeuring")[:60]
actions.append(f"@{row.get('agent_key')}: {label}")
# —— Cucina B2C (compact) ——
cu_lines: list[str] = []
if rev_stats:
cu_lines.append(
f"Marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen"
)
cu_lines.append(
f"Retail: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} partnerships"
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
deal_bits = []
for p in cu_projects[:4]:
name = (p.get("name") or "").strip()
if not name or "marketing" in name.lower() or "boekhouder" in name.lower():
continue
mm = p.get("margin_month")
bit = name
if mm:
bit += f" {_fmt_eur(mm)}/m"
if p.get("open_objectives"):
bit += f" · {p.get('open_objectives')} stappen"
deal_bits.append(bit)
if len(deal_bits) >= 3:
break
if deal_bits:
cu_lines.append("Deals: " + " · ".join(deal_bits))
if data.get("promo_campaigns"):
cu_lines.append(f"Marketing: {data.get('promo_campaigns')} actieve promo's")
if halal_items:
rec = halal_items[0]
cu_lines.append(
f"Halal-kans: {rec.get('chain')} · {rec.get('city')} (score {int(float(rec.get('halal_opportunity_score') or rec.get('score') or 0))})"
)
sections = [
{"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines},
{"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines},
]
# —— Focus vandaag (business only, no sysops) ——
focus_lines: list[str] = []
for row in (data.get("milestones_pending") or [])[:2]:
actions.append(f"Milestone: {row.get('title')}")
for row in (revenue.get("by_brand") or {}).get("cucina") or []:
if row.get("open_objectives"):
actions.append(f"Cucina follow-up: {row.get('name')}")
title = (row.get("title") or "").strip()
if title:
focus_lines.append(title[:90])
for p in cu_projects:
if p.get("open_objectives") and (p.get("name") or "").lower() in ("ah", "jumbo", "picnic", "plus"):
focus_lines.append(f"Cucina follow-up: {p.get('name')} ({p.get('open_objectives')} open stappen)")
break
for rec in halal_items[:2]:
actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
reason = ((rec.get("reasons") or [""])[0] or "")[:60]
focus_lines.append(f"Outreach {rec.get('chain')} · {rec.get('city')}" + (f" — {reason}" if reason else ""))
if export.get("tenders_open"):
actions.append(f"Wereldexport: {export.get('tenders_open')} open tenders bekijken")
if not actions:
actions.extend([
"Retail 360: top-3 halal-gap filialen benaderen",
"Marketing Hub: kant-en-klaar trends checken",
])
focus_lines.append(f"Bekijk {export.get('tenders_open')} open export-tenders")
# Skip sysops / infra approvals entirely
if not focus_lines:
focus_lines.append("Pipeline en Cucina retail-deals nalopen")
sections.append({"brand": "platform", "title": "Focus vandaag", "icon": "🎯", "lines": focus_lines[:4]})
# —— Trends ——
trend_lines: list[str] = []
for tr in trends[:4]:
name = (tr.get("trend_name") or tr.get("name") or "").strip()
score = tr.get("opportunity_score")
if not name:
continue
if score is not None:
pct = int(float(score) * 100) if float(score) <= 1 else int(float(score))
trend_lines.append(f"{name} ↑{pct}%")
else:
trend_lines.append(name)
if market.get("best_performer"):
bp = market["best_performer"]
trend_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
if not trend_lines:
trend_lines.append("Geen trenddata — check Retail 360")
# —— Nieuws (max 5, food only) ——
news: list[dict[str, Any]] = []
seen: set[str] = set()
skip = ("mediamarkt", "ceconomy", "elektro", "airco", "gaming", "smartphone", "sysops", "gitea", "backup")
for row in list(data.get("trending_food") or []) + list(data.get("rss_highlights") or []) + list(data.get("rss_live") or []):
title = str(row.get("title") or "").strip()
low = title.lower()
if not title or title in seen:
continue
if any(x in low for x in skip):
continue
seen.add(title)
news.append({
"title": title[:110],
"link": row.get("link") or "#",
"source": row.get("feed_name") or "RSS",
})
if len(news) >= 5:
break
actions = focus_lines[:4]
long_term = [
"Cucina: schaal supermarkt-listings (AH, Jumbo, PLUS) naar actieve partnerships",
"Foodlinkk: distributeurs in Wereldexport koppelen aan CRM-deals",
"Revenue Cockpit: open stappen en marges wekelijks reviewen",
"Cucina: schaal AH/Jumbo/PLUS naar actieve partnerships",
"Foodlinkk: export-distributeurs koppelen aan CRM-deals",
"Revenue: open stappen wekelijks reviewen",
]
if rev_goals.get("horizon_text"):
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
# Platte summary voor backwards compat (notifications)
summary = f"{data['date']} · Foodlinkk B2B + Cucina B2C · {len(activity)} activiteiten vandaag"
summary = f"{data.get('date')} · Foodlinkk B2B + Cucina B2C"
return {
"summary": summary,
"sections": sections,
"actions": actions[:6],
"long_term": long_term[:5],
"b2b": fl_lines,
"b2c": cu_lines,
"focus": focus_lines[:4],
"trends": trend_lines[:5],
"news": news,
"actions": actions,
"long_term": long_term[:4],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"activity_count": len(data.get("activity_log") or []),
"source": "live",
}
@@ -885,7 +877,7 @@ def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Herman via OpenCode*",
"",
"## KPI's",
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
@@ -935,41 +927,11 @@ def build_template_report(data: dict[str, Any]) -> str:
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"):
lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
fresh_appr = _fresh_approvals(data.get("pending_approval_requests") or [])
if fresh_appr:
lines.extend(["", "## Open beslissingen"])
for row in fresh_appr[:8]:
lines.append(f"- **{row.get('agent_key')}**: {row.get('title')}")
return "\n".join(lines)
@@ -983,31 +945,60 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
news_lines = ""
for row in (data.get("trending_food") or [])[:5]:
title = str(row.get("title") or "").strip()
if title:
news_lines += f"- {title}\n"
fresh = _fresh_approvals(data.get("pending_approval_requests") or [])
decision_lines = ""
for row in fresh[:5]:
decision_lines += f"- @{row.get('agent_key')}: {row.get('title') or row.get('action_type')}\n"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
"Schrijf een strak CEO-memo in het Nederlands (markdown) voor Aïssa (Foodlinkk / Cucina, "
"halal kant-en-klaar). Geen IT, geen SysOps, geen NAS, geen agent-mesh.\n\n"
"## Stand van zaken\n(4-6 zinnen: business context vandaag)\n\n"
"## Pipeline & revenue\n(bullets met cijfers en wat dat betekent)\n\n"
"## Retail & partnerships\n(supermarkten, milestones, halal-kansen)\n\n"
"## Markt\n(max 3 relevante food/retail signalen)\n\n"
"## Beslissingen vandaag\n(3-5 concrete CEO-acties)\n\n"
f"Data ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"- {data.get('supermarkets',0)} filialen, {data.get('crm_partnerships',0)} partnerships\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
f"Milestones:\n{ms_lines or '- geen'}\n"
f"Markt:\n{news_lines or '- geen'}\n"
f"Open beslissingen:\n{decision_lines or '- geen'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf als een executive briefing: "
"helder, zakelijk, actionable. Focus op omzet, retail, partnerships en keuzes. "
"Noem geen servers, backups, Gitea, NAS of SysOps. "
"Antwoord UITSLUITEND met de markdown-memo. Geen voorwoord, geen bestandsnamen, "
"geen vragen aan de gebruiker, geen tools of Telegram-aanbod."
)
try:
return await llm_router.generate(prompt, system=system, timeout=120.0)
raw = await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
text = (raw or "").strip()
for marker in ("## Stand van zaken", "## Samenvatting", "# "):
idx = text.find(marker)
if idx > 0:
text = text[idx:]
break
cut_at = len(text)
for m in ["Wil je dat ik", "Wil je deze", "Shall I", "Memo geschreven"]:
idx = text.find(m)
if 0 < idx < cut_at:
cut_at = idx
text = text[:cut_at].rstrip()
return text.strip()
return text.strip()
def _fallback_summary(data: dict[str, Any]) -> str:
@@ -1019,17 +1010,12 @@ def _fallback_summary(data: dict[str, Any]) -> str:
lines.extend(["", "## Lange termijn focus"])
for a in digest["long_term"]:
lines.append(f"- {a}")
activity = data.get("activity_log") or []
if activity:
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
for entry in activity[:8]:
lines.append(f"- {_strip_activity_prefix(entry)}")
return "\n".join(lines)
def _save_briefing(content: str, data: dict[str, Any]) -> None:
safe = serialize_stats(data)
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
metadata = {"stats": safe, "model": getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent", "type": "daily_ceo_report"}
try:
execute(
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
@@ -1067,7 +1053,7 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
+97
View File
@@ -119,11 +119,77 @@ def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dic
return steps
async def _chat_via_llm_router(
message: str,
channel: str,
*,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
from app.services import hermes_memory, llm_router
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel)
from app.config import settings as _settings
display = "Aïssa" if str(hermes_user) == str(_settings.HERMES_CEO_CHAT_ID) else (username or "gebruiker")
system = hermes_memory.continuity_system(hermes_user, display_name=display)
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
feed_items = await hermes_memory.fetch_recent_feed(hermes_user)
hist = hermes_memory.feed_to_messages(feed_items)
if hist:
messages.append({
"role": "system",
"content": (
f"Recente Telegram-geschiedenis voor chat_id={hermes_user} "
f"({len(hist)} berichten, oud→nieuw). Behandel dit als jouw geheugencontext."
),
})
messages.extend(hist)
messages.append({"role": "user", "content": message})
reply, meta = await llm_router.chat_messages(messages, timeout=180.0, user=hermes_user)
provider = meta.get("provider_label") or "LLM"
model = meta.get("model") or ""
await _log_event(
"herman",
"llm_chat",
f"Herman via {provider}",
message[:2000],
{
"provider": meta,
"channel": channel,
"hermes_user": hermes_user,
"memory_messages": len(hist),
"username": username,
},
channel=channel,
)
return {
"agent": "herman",
"agent_label": f"Herman · {provider}",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": (
f"Cloud LLM · {provider}"
+ (f" · {model}" if model else "")
+ f" · memory:{hermes_user}"
+ (f" · feed:{len(hist)}" if hist else "")
),
"agent_steps": _agent_steps([], f"Via {provider} + Hermes memory"),
"llm_provider": meta,
"hermes_user": hermes_user,
"memory_messages": len(hist),
}
async def chat(
message: str,
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: str | None = None,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
use_voice_router = (
channel in voice_export_actions.VOICE_CHANNELS
@@ -344,6 +410,24 @@ async def chat(
"reply": f"Kon geen afbeelding genereren: {exc}",
}
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
if backend in ("hermes", "router", "openrouter", "llm"):
try:
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as exc:
hint = (
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
if backend == "hermes"
else "Vul OpenRouter key in via Instellingen → AI / LLM."
)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"LLM provider niet beschikbaar: {exc}. {hint}",
}
# orchestrator | auto — try Herman orchestrator first
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
@@ -373,6 +457,19 @@ async def chat(
"crm_loaded": data.get("crm_loaded", False),
}
except Exception as exc:
if backend == "auto":
try:
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as llm_exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Orchestrator offline ({exc}). "
f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
"Check Hermes (:8642) of Instellingen → AI / LLM."
),
}
return {
"agent": "herman",
"agent_label": "Herman",
+83
View File
@@ -0,0 +1,83 @@
"""Koppel cockpit Herman-chat aan Hermes/Telegram memory."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import settings
log = logging.getLogger("cockpit.hermes_memory")
_USERNAME_MAP = {
"aissa": "ceo",
"aïssa": "ceo",
"aissa zarioh": "ceo",
"mo": "cto",
"mohamed": "cto",
}
def resolve_hermes_user(
*,
username: str | None = None,
role: str | None = None,
channel: str | None = None,
) -> str:
"""OpenAI-compatible `user` field = Telegram chat_id for Hermes memory."""
uname = (username or "").strip().lower()
r = (role or "").strip().lower()
mapped = _USERNAME_MAP.get(uname)
if mapped == "ceo" or r in ("ceo", "admin") or uname in ("aissa", "aïssa"):
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
if mapped == "cto" or r == "cto" or uname in ("mo", "mohamed"):
return str(settings.HERMES_CTO_CHAT_ID or "789036463")
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
async def fetch_recent_feed(chat_id: str | int, limit: int | None = None) -> list[dict[str, Any]]:
limit = limit or int(getattr(settings, "HERMES_MEMORY_LIMIT", 24) or 24)
base = (settings.HERMES_FEED_URL or "").rstrip("/")
if not base:
return []
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{base}/feed", params={"chat_id": int(chat_id), "limit": limit})
if r.status_code != 200:
return []
items = (r.json() or {}).get("items") or []
return list(reversed(items))
except Exception as exc:
log.warning("hermes feed fetch failed: %s", exc)
return []
def feed_to_messages(items: list[dict[str, Any]], *, max_chars: int = 12000) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
total = 0
for it in items:
text = (it.get("content_text") or "").strip()
if not text:
continue
role_raw = (it.get("role") or "").lower()
role = "assistant" if role_raw in ("assistant", "hermes", "herman") else "user"
if role == "assistant" and (it.get("direction") or "") == "in":
role = "user"
entry = {"role": role, "content": text[:2000]}
total += len(entry["content"])
if total > max_chars:
break
out.append(entry)
return out
def continuity_system(chat_id: str, display_name: str | None = None) -> str:
name = display_name or ("Aïssa" if str(chat_id) == str(settings.HERMES_CEO_CHAT_ID) else "de gebruiker")
return (
"Je bent Herman / Hermes, dezelfde AI Co-CEO als in Telegram. "
f"Deze cockpit-chat is gekoppeld aan Telegram-sessie chat_id={chat_id} ({name}). "
"Gebruik je bestaande Hermes-memory en recente Telegram-geschiedenis. "
"Doe alsof dit dezelfde doorlopende conversatie is — geen nieuw persoon, geen amnesie. "
"Antwoord in het Nederlands, bondig en actiegericht."
)
+213 -5
View File
@@ -48,13 +48,17 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
"label": "OpenRouter",
"api_base_url": "https://openrouter.ai/api/v1",
"models": [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-flash:free",
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-r1:free",
"meta-llama/llama-3.3-70b-instruct:free",
],
"default_model": "deepseek/deepseek-v4-flash",
"needs_key": True,
"signup_url": "https://openrouter.ai/",
"hint": "Veel gratis modellen via één API.",
"signup_url": "https://openrouter.ai/keys",
"hint": "Eén API key · DeepSeek V4 Flash via openrouter.ai. Vul key in Instellingen → AI.",
},
"mistral": {
"label": "Mistral AI",
@@ -64,6 +68,14 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
"signup_url": "https://console.mistral.ai/",
"hint": "EU-hosted · gratis proef tier.",
},
"hermes": {
"label": "Hermes Agent (Herman)",
"api_base_url": "http://10.4.7.10:8642/v1",
"models": ["hermes-agent"],
"default_model": "hermes-agent",
"needs_key": True,
"hint": "OpenAI-compatible API van Hermes Agent LXC — OpenCode eronder.",
},
"custom_openai": {
"label": "Custom OpenAI-compatible",
"api_base_url": "",
@@ -74,6 +86,72 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
}
OPENROUTER_PLACEHOLDER_LABEL = "OpenRouter · DeepSeek V4 Flash"
def ensure_openrouter_placeholder() -> None:
"""Seed inactive OpenRouter row so user can fill API key later in Settings."""
row = fetch_one(
"SELECT id FROM llm_providers WHERE provider_type = 'openrouter' LIMIT 1"
)
if row:
return
execute(
"""INSERT INTO llm_providers (
label, provider_type, api_base_url, api_key, model,
is_active, is_default, extra_config, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())""",
(
OPENROUTER_PLACEHOLDER_LABEL,
"openrouter",
"https://openrouter.ai/api/v1",
"",
"deepseek/deepseek-v4-flash",
False,
False,
json.dumps({"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"}),
),
)
def _env_openrouter_provider() -> dict[str, Any] | None:
from app.config import settings as app_settings
key = (app_settings.OPENROUTER_API_KEY or "").strip()
if not key:
return None
return {
"id": -1,
"label": "OpenRouter (environment)",
"provider_type": "openrouter",
"api_base_url": (app_settings.OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/"),
"api_key": key,
"model": app_settings.OPENROUTER_MODEL or "deepseek/deepseek-v4-flash",
"extra_config": {"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"},
}
def _env_hermes_provider() -> dict[str, Any] | None:
from app.config import settings as app_settings
key = (app_settings.HERMES_API_KEY or "").strip()
base = (app_settings.HERMES_API_BASE or "").strip()
if not key or not base:
return None
return {
"id": -2,
"label": "Hermes Agent (Herman)",
"provider_type": "hermes",
"api_base_url": base.rstrip("/"),
"api_key": key,
"model": app_settings.HERMES_API_MODEL or "hermes-agent",
"extra_config": {"temperature": 0.35, "max_tokens": 4096},
}
def list_presets() -> list[dict[str, Any]]:
out = []
for key, meta in LLM_PRESETS.items():
@@ -125,8 +203,27 @@ def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
from app.config import settings as app_settings
backend = (app_settings.HERMAN_LLM_BACKEND or "auto").lower()
hermes = _env_hermes_provider()
if hermes and backend in ("hermes", "auto"):
# Prefer Hermes when configured (Ollama is often offline)
if backend == "hermes" or provider_id is None:
row = get_provider(provider_id) if provider_id else None
# Explicit provider_id still wins when set
if provider_id and row:
pass
else:
return hermes
row = get_provider(provider_id)
if not row:
if hermes:
return hermes
env_or = _env_openrouter_provider()
if env_or:
return env_or
return {
"id": 0,
"label": "Ollama lokaal",
@@ -136,6 +233,19 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
"model": settings.OLLAMA_MODEL,
"extra_config": {},
}
env_or = _env_openrouter_provider()
ptype = (row.get("provider_type") or "").lower()
if hermes and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
return hermes
if env_or and ptype == "openrouter" and not (row.get("api_key") or "").strip():
merged = dict(row)
merged["api_key"] = env_or["api_key"]
if not (merged.get("model") or "").strip():
merged["model"] = env_or["model"]
return merged
if env_or and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
if backend in ("router", "openrouter"):
return env_or
return row
@@ -145,6 +255,9 @@ async def chat_messages(
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> tuple[str, dict[str, Any]]:
"""Returns (reply_text, meta dict with provider info)."""
prov = resolve_provider(provider_id)
@@ -199,9 +312,14 @@ async def chat_messages(
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(extra.get("temperature", 0.4)),
"max_tokens": int(extra.get("max_tokens", 2048)),
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 2048)),
}
# Hermes: `user` = Telegram chat_id → koppelt API-chat aan bestaande memory/sessie
if user:
payload["user"] = str(user)
elif ptype == "hermes":
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
@@ -222,9 +340,99 @@ async def chat_messages(
"provider_type": ptype,
"provider_label": prov.get("label"),
"model": use_model,
"hermes_user": payload.get("user"),
}
async def stream_chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 90.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
):
"""Yield text deltas from Hermes/OpenAI-compatible streaming chat."""
prov = resolve_provider(provider_id)
ptype = (prov.get("provider_type") or "ollama").lower()
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
if ptype != "hermes":
# non-stream fallback: one chunk
reply, _meta = await chat_messages(
messages,
provider_id=provider_id,
model=model,
timeout=timeout,
user=user,
max_tokens=max_tokens,
temperature=temperature,
)
if reply:
yield reply
return
api_key = (prov.get("api_key") or "").strip()
if not api_key:
raise RuntimeError("Geen Hermes API key")
base = (prov.get("api_base_url") or "").strip().rstrip("/")
if not base:
raise RuntimeError("Geen Hermes API URL")
extra = prov.get("extra_config") or {}
if isinstance(extra, str):
try:
extra = json.loads(extra)
except Exception:
extra = {}
url = f"{base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.35)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 512)),
"stream": True,
}
if user:
payload["user"] = str(user)
else:
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=headers, json=payload) as resp:
if resp.status_code >= 400:
body = (await resp.aread()).decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"Hermes stream fout ({resp.status_code}): {body}")
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith(":"):
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
if data == "[DONE]":
break
continue
try:
obj = json.loads(data)
except Exception:
continue
choices = obj.get("choices") or []
if not choices:
continue
delta = (choices[0].get("delta") or {}).get("content")
if delta:
yield delta
async def generate(
prompt: str,
system: str | None = None,
+180
View File
@@ -0,0 +1,180 @@
"""Daily Herman War Room digest → Telegram (Aïssa + Mo)."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import llm_router
log = logging.getLogger("cockpit.warroom_digest")
def _recipients() -> list[tuple[str, str]]:
"""(chat_id, display_name)"""
return [
(str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), "Aïssa"),
(str(getattr(settings, "HERMES_CTO_CHAT_ID", None) or "789036463"), "Mo"),
]
def collect_warroom_day(*, hours: int = 24, limit: int = 120) -> list[dict[str, Any]]:
hours = max(1, min(int(hours), 72))
rows = fetch_all(
"""
SELECT m.id, m.sender_type, m.agent_key, m.username, m.content, m.created_at, r.kind, r.title
FROM warroom_messages m
JOIN warroom_rooms r ON r.id = m.room_id
WHERE m.created_at >= NOW() - make_interval(hours => %s)
AND r.kind = 'warroom'
ORDER BY m.created_at ASC
LIMIT %s
""",
(hours, limit),
)
out = []
for r in rows or []:
item = dict(r)
if item.get("created_at") and hasattr(item["created_at"], "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def _transcript(messages: list[dict[str, Any]], *, max_chars: int = 6000) -> str:
lines: list[str] = []
for m in messages:
st = m.get("sender_type")
if st == "user":
who = m.get("username") or "user"
elif st == "agent":
who = m.get("agent_key") or "agent"
else:
who = "system"
content = (m.get("content") or "").strip().replace("\n", " ")
if not content:
continue
lines.append(f"- {who}: {content[:280]}")
text = "\n".join(lines)
return text[-max_chars:]
async def generate_digest_text(messages: list[dict[str, Any]]) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if not messages:
return (
f"📋 War Room dagrapport {day}\n\n"
"Geen activiteit in #war-room vandaag. Agents stonden klaar; geen overleg gelogd."
)
transcript = _transcript(messages)
system = (
"Je bent Herman, Co-CEO van Foodlinkk. Schrijf een beknopte Nederlandse "
"Telegram-samenvatting van het War Room-overleg voor Aïssa (CEO) en Mo (CTO).\n"
"Structuur:\n"
f"1) Kop: 📋 War Room dagrapport {day}\n"
"2) 3–6 bullets: wat agents bespraken (pipeline, deals, acties, blockers)\n"
"3) Sectie 'Acties / open punten' met concrete next steps\n"
"4) 1 zin afsluiting\n"
"Max ~350 woorden. Geen @zelf-prefix. Geen 'agents staan uit'."
)
user = f"War Room transcript (nieuwste onderaan):\n{transcript}"
reply, _meta = await llm_router.chat_messages(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
timeout=90.0,
user=str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"),
max_tokens=900,
temperature=0.3,
)
text = (reply or "").strip()
if not text.startswith("📋"):
text = f"📋 War Room dagrapport {day}\n\n{text}"
return text[:3500]
async def _deliver_telegram(chat_id: str, name: str, digest: str) -> dict[str, Any]:
"""Ask Hermes agent to deliver the digest into that Telegram chat."""
system = (
f"Je bent Herman. Bezorg het volgende War Room-dagrapport NU via Telegram "
f"aan {name} (chat_id={chat_id}). Stuur de tekst zo letterlijk mogelijk. "
"Bevestig kort of verzonden."
)
try:
reply, meta = await llm_router.chat_messages(
[
{"role": "system", "content": system},
{"role": "user", "content": digest},
],
timeout=120.0,
user=str(chat_id),
max_tokens=200,
temperature=0.1,
)
return {
"ok": True,
"chat_id": chat_id,
"name": name,
"confirm": (reply or "")[:240],
"provider": (meta or {}).get("provider_type"),
}
except Exception as exc:
log.exception("warroom digest deliver failed %s", chat_id)
return {"ok": False, "chat_id": chat_id, "name": name, "error": str(exc)}
def _persist(digest: str, message_count: int, deliveries: list[dict[str, Any]]) -> None:
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
"herman",
"warroom_digest",
"warroom_daily_digest",
"War Room dagrapport verstuurd",
digest[:4000],
"completed",
"telegram",
__import__("json").dumps(
{
"message_count": message_count,
"deliveries": deliveries,
}
),
),
)
except Exception:
log.exception("persist digest event failed")
try:
from app.services import agent_chat
agent_chat.post_system_line(
"📤 Dagrapport verstuurd naar Aïssa + Mo via Telegram.\n\n" + digest[:1500],
agent_key="herman",
metadata={"kind": "warroom_digest", "deliveries": deliveries},
)
except Exception:
log.exception("post digest to warroom failed")
async def run_daily_digest(*, hours: int = 24, deliver: bool = True) -> dict[str, Any]:
messages = collect_warroom_day(hours=hours)
digest = await generate_digest_text(messages)
deliveries: list[dict[str, Any]] = []
if deliver:
for chat_id, name in _recipients():
deliveries.append(await _deliver_telegram(chat_id, name, digest))
_persist(digest, len(messages), deliveries)
return {
"ok": True,
"message_count": len(messages),
"digest": digest,
"deliveries": deliveries,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
+10
View File
@@ -0,0 +1,10 @@
{
auto_https off
admin off
}
:8643 {
tls /certs/cert.pem /certs/key.pem
encode gzip
reverse_proxy cockpit:8600
}
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD
DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ
MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP
0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL
45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f
stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3
By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg
MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw
hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs
MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d
KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN
L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2
kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC
9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV
KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x
p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/
BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV
BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag
3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg
8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u
k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64
HJOEFqjE6LCTsSnqN2da2GGm5AY=
-----END CERTIFICATE-----
+16
View File
@@ -0,0 +1,16 @@
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = 10.4.7.18
O = Foodlinkk Cockpit
[v3_req]
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = 10.4.7.18
IP.2 = 127.0.0.1
DNS.1 = localhost
DNS.2 = cockpit.foodlinkk.local
+9 -8
View File
@@ -1,11 +1,12 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.4
python-multipart==0.0.12
psycopg2-binary==2.9.9
httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
fastapi==0.115.6
httpx==0.27.2
itsdangerous>=2.1.0
jinja2==3.1.4
openpyxl>=3.1.0
psycopg2-binary==2.9.9
python-multipart==0.0.12
textblob==0.18.0.post0
tweepy==4.15.0
uvicorn[standard]==0.32.1
websockets==13.1
+62 -1
View File
@@ -3,7 +3,9 @@ function revenueCockpit() {
loading: false,
saving: false,
savedAt: '',
_pollStop: null,
viewMode: 'cockpit',
visualMode: 'table',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
@@ -44,8 +46,20 @@ function revenueCockpit() {
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
const saved = localStorage.getItem('rc-visual-mode');
if (saved === 'table' || saved === 'bars' || saved === 'cards') {
this.visualMode = saved;
} else if (window.matchMedia('(max-width: 768px)').matches) {
this.visualMode = 'cards';
}
await this.loadLive();
await this.loadDbQuiet();
if (window.CockpitLive) {
this._pollStop = CockpitLive.startPolling(async () => {
await this.loadLive();
await this.loadDbQuiet();
}, 45000);
}
},
projectKey(p) {
@@ -119,6 +133,53 @@ function revenueCockpit() {
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
setVisualMode(mode) {
if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return;
this.visualMode = mode;
localStorage.setItem('rc-visual-mode', mode);
},
styleLabel(style) {
const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' };
return map[style] || 'Neutraal';
},
rankedRows() {
const rows = this.visualRows().slice();
rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0));
return rows;
},
cardRows() {
return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim());
},
async setCockpitStyle(style) {
const p = this.selectedProject;
if (!p) return;
if (!p.db_id) {
p.row_style = style;
if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info');
return;
}
try {
await fetch('/api/revenue-cockpit/projects/' + p.db_id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
p.row_style = style;
const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p));
if (pi >= 0) this.projects[pi].row_style = style;
const di = this.dbProjects.findIndex((x) => x.id === p.db_id);
if (di >= 0) this.dbProjects[di].row_style = style;
if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success');
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
@@ -167,7 +228,7 @@ function revenueCockpit() {
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%)';
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
},
toggleExpand(p) {
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+114
View File
@@ -138,6 +138,12 @@
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1200;
display: none;
pointer-events: none;
}
.agent-events-backdrop.is-open {
display: block;
pointer-events: auto;
}
.agent-events-drawer {
position: fixed;
@@ -208,3 +214,111 @@
white-space: pre-wrap;
}
.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
/* Live working animations */
.agent-soul-card {
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
background: rgba(12, 18, 28, 0.85);
}
.agent-soul-card.is-working {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12);
animation: agent-card-pulse 2.2s ease-in-out infinite;
}
.agent-soul-card.is-busy {
border-color: rgba(251, 191, 36, 0.65);
box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12);
animation: agent-card-pulse-busy 1.4s ease-in-out infinite;
}
.agent-soul-card.is-working .agent-char-wrap,
.agent-soul-card.is-busy .agent-char-wrap {
animation: agent-avatar-bob 1.1s ease-in-out infinite;
}
.agent-soul-card.is-working .agent-char-wrap::after,
.agent-soul-card.is-busy .agent-char-wrap::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%);
animation: agent-glow-sweep 2.4s ease-in-out infinite;
}
.agent-soul-card.is-busy .agent-char-wrap::after {
background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%);
}
.agent-activity-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 12px;
margin-left: 0.35rem;
vertical-align: middle;
}
.agent-activity-eq span {
width: 3px;
border-radius: 2px;
background: #38bdf8;
animation: agent-eq 0.9s ease-in-out infinite;
}
.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; }
.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; }
.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; }
.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; }
.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; }
.agent-status-pill.active {
border-color: rgba(34,197,94,0.45);
color: #86efac;
background: rgba(34,197,94,0.1);
}
.agent-status-pill.busy {
border-color: rgba(245,158,11,0.5);
color: #fcd34d;
background: rgba(245,158,11,0.12);
animation: agent-pill-blink 1.2s ease-in-out infinite;
}
.agent-status-pill.idle .dot { background: #64748b; }
.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; }
.agent-status-pill.offline { color: #64748b; }
@keyframes agent-card-pulse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes agent-card-pulse-busy {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.01); }
}
@keyframes agent-avatar-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes agent-glow-sweep {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.85; }
}
@keyframes agent-eq {
0%, 100% { transform: scaleY(0.55); opacity: 0.7; }
50% { transform: scaleY(1.25); opacity: 1; }
}
@keyframes agent-pill-blink {
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
}
/* Brighter, readable agent avatars on dark UI */
.agent-char-wrap {
background: linear-gradient(145deg, #1e4a6e, #163a58) !important;
border: 1px solid rgba(56, 189, 248, 0.35) !important;
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25);
overflow: hidden;
}
.agent-char-wrap .agent-char-svg {
filter: brightness(1.18) contrast(1.08) saturate(1.15);
}
.agent-soul-card .agent-char-wrap {
width: 72px;
height: 72px;
}
+664
View File
@@ -0,0 +1,664 @@
/* Foodlinkk War Room — compact, one viewport */
.wr-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 0.6rem;
height: calc(100vh - 150px);
max-height: calc(100vh - 150px);
}
.wr-page-header { margin-bottom: 0.4rem; }
.wr-page-header h1 { font-size: 1.2rem; margin: 0; }
.wr-page-header .hm-sub { margin: 0.15rem 0 0; font-size: 0.85rem; }
.wr-roster {
border-right: 1px solid rgba(255,255,255,0.08);
padding-right: 0.4rem;
overflow: auto;
min-height: 0;
}
.wr-roster h3 {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.7;
margin: 0 0 0.35rem;
}
.wr-room-btn, .wr-agent-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.45rem;
text-align: left;
background: transparent;
border: 1px solid transparent;
color: inherit;
padding: 0.32rem 0.4rem;
border-radius: 8px;
cursor: pointer;
}
.wr-room-btn:hover, .wr-agent-btn:hover,
.wr-room-btn.active, .wr-agent-btn.active {
background: rgba(255, 215, 0, 0.08);
border-color: rgba(255, 215, 0, 0.25);
}
.wr-agent-meta { display: flex; flex-direction: column; min-width: 0; }
.wr-agent-meta strong { font-size: 0.8rem; }
.wr-agent-meta small { opacity: 0.65; font-size: 0.68rem; }
.wr-roster .agent-char-wrap { width: 40px; height: 40px; flex: 0 0 40px; }
.wr-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
}
.wr-head {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 0.5rem; margin-bottom: 0.35rem; flex: 0 0 auto;
}
.wr-head h2 { margin: 0; font-size: 1rem; letter-spacing: 0.04em; }
.wr-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.wr-log {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.wr-empty { opacity: 0.6; margin: 0.35rem 0; font-size: 0.85rem; }
.wr-bubble {
display: flex; gap: 0.45rem; margin-bottom: 0.45rem;
}
.wr-bubble.is-user { flex-direction: row-reverse; }
.wr-bubble.is-system { opacity: 0.85; }
.wr-avatar { width: 44px; height: 44px; flex: 0 0 44px; }
.wr-avatar img, .wr-avatar .agent-char-wrap {
width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
}
.wr-avatar .agent-char-wrap svg { display: block; width: 44px; height: 44px; }
.wr-avatar-fallback {
display: grid; place-items: center; width: 44px; height: 44px;
border-radius: 50%; background: #1e3a5f; font-size: 0.65rem; font-weight: 700;
}
.wr-bubble-body {
max-width: min(680px, 85%);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 0.35rem 0.55rem;
}
.wr-bubble.is-user .wr-bubble-body {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.28);
}
.wr-bubble.is-agent .wr-bubble-body {
background: rgba(56, 189, 248, 0.08);
border-color: rgba(56, 189, 248, 0.22);
}
.wr-bubble.is-typing .wr-bubble-body { border-style: dashed; }
.wr-bubble-meta { font-size: 0.68rem; opacity: 0.7; margin-bottom: 0.1rem; }
.wr-bubble-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.88rem;
line-height: 1.3;
}
.wr-typing-cursor::after {
content: "▌";
animation: wrBlink 1s steps(1) infinite;
margin-left: 1px;
}
@keyframes wrBlink { 50% { opacity: 0; } }
.wr-attach-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; }
.wr-attach-card {
display: block; max-width: 300px; text-decoration: none; color: inherit;
border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; overflow: hidden;
background: rgba(0,0,0,0.2);
}
.wr-attach-card img { display: block; width: 100%; max-height: 220px; object-fit: cover; }
.wr-attach-card span {
display: block; padding: 0.25rem 0.4rem; font-size: 0.7rem; opacity: 0.85;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wr-composer {
margin-top: 0.4rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.35rem;
align-items: end;
flex: 0 0 auto;
}
.wr-composer-tools {
grid-column: 1 / -1;
display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
}
.wr-pending {
display: flex; flex-wrap: wrap; gap: 0.3rem; grid-column: 1 / -1;
}
.wr-pending-chip {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.7rem; padding: 0.15rem 0.4rem;
border-radius: 999px; background: rgba(255,255,255,0.08);
}
.wr-pending-chip button {
border: 0; background: transparent; color: inherit; cursor: pointer; opacity: 0.7;
}
.wr-emoji-pop {
display: flex; flex-wrap: wrap; gap: 0.15rem;
max-width: 280px; padding: 0.3rem;
background: #0f172a; border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
}
.wr-emoji-pop button {
border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; line-height: 1;
padding: 0.12rem;
}
/* Call overlay */
.wr-call-overlay {
position: fixed; inset: 0; z-index: 11000;
background: rgba(3, 8, 18, 0.82);
display: none; align-items: center; justify-content: center;
padding: 1rem; pointer-events: none;
}
.wr-call-panel {
width: min(1100px, 96vw);
height: min(780px, 92vh);
background: #0b1220;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 14px;
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
.wr-call-head {
display: flex; justify-content: space-between; align-items: center;
gap: 0.75rem; padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.wr-call-head h3 { margin: 0; font-size: 1rem; }
.wr-call-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.wr-call-frame {
flex: 1; border: 0; width: 100%; background: #000;
}
.wr-call-hint {
margin: 0; padding: 0.45rem 0.85rem; font-size: 0.78rem; opacity: 0.75;
border-top: 1px solid rgba(255,255,255,0.06);
}
.wr-popup-body {
margin: 0; height: 100vh; overflow: hidden;
background: #070b14; color: #e8eef8;
}
.wr-popup-shell { padding: 0.55rem; height: 100vh; box-sizing: border-box; }
.wr-popup-shell .wr-layout {
height: calc(100vh - 1.1rem);
max-height: calc(100vh - 1.1rem);
}
@media (max-width: 900px) {
.wr-layout {
grid-template-columns: 1fr;
height: auto;
max-height: none;
}
.wr-roster { max-height: 140px; border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.wr-main { height: calc(100vh - 280px); }
}
.wr-composer-wrap { position: relative; grid-column: 1 / 2; }
.wr-mention-pop {
position: absolute;
left: 0; right: 0; bottom: calc(100% + 4px);
max-height: 220px; overflow: auto;
background: #0f172a;
border: 1px solid rgba(255,215,0,0.35);
border-radius: 10px;
z-index: 20;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.wr-mention-item {
width: 100%; display: flex; align-items: center; gap: 0.45rem;
text-align: left; border: 0; background: transparent; color: inherit;
padding: 0.4rem 0.55rem; cursor: pointer;
}
.wr-mention-item:hover, .wr-mention-item.active {
background: rgba(255, 215, 0, 0.12);
}
.wr-mention-item strong { font-size: 0.85rem; }
.wr-mention-item small { opacity: 0.65; margin-left: 0.25rem; }
.wr-agent-btn .wr-dm-hint {
margin-left: auto; font-size: 0.65rem; opacity: 0.45;
}
.wr-bubble-meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
.wr-msg-del {
margin-left: auto;
opacity: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.75rem;
padding: 0 0.2rem;
line-height: 1;
}
.wr-bubble:hover .wr-msg-del { opacity: 0.55; }
.wr-msg-del:hover { opacity: 1 !important; color: #e74c3c; }
.wr-call-join {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
min-height: 280px;
}
.wr-call-title { margin: 0; font-size: 1.15rem; font-weight: 600; }
.wr-call-join-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
.wr-call-link {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
opacity: 0.75;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: rgba(255,255,255,0.06);
}
.wr-main { position: relative; z-index: 1; }
.wr-composer {
position: relative;
z-index: 6;
pointer-events: auto !important;
}
.wr-composer textarea,
.wr-composer .form-input {
pointer-events: auto !important;
position: relative;
z-index: 7;
}
.wr-log { position: relative; z-index: 1; }
.wr-call-overlay {
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
}
.btn.is-recording, button.is-recording {
background: #c0392b !important;
color: #fff !important;
animation: wr-mic-pulse 1s ease-in-out infinite;
}
@keyframes wr-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(192,57,43,0.5); }
50% { box-shadow: 0 0 0 8px rgba(192,57,43,0); }
}
.wr-voice-overlay {
position: fixed !important; inset: 0 !important; z-index: 11000 !important;
background: rgba(3, 8, 18, 0.88);
display: none !important;
align-items: center; justify-content: center;
padding: 1rem;
pointer-events: none !important;
visibility: hidden !important;
}
.wr-voice-overlay.is-open {
display: flex !important;
pointer-events: auto !important;
visibility: visible !important;
}
.wr-voice-panel {
width: min(920px, 96vw);
max-height: 92vh;
overflow: auto;
background: #0b1220;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
padding: 1rem 1.1rem 1.15rem;
box-shadow: 0 24px 70px rgba(0,0,0,0.5);
color: #e8eef8;
}
.wr-voice-head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.wr-voice-head h3 { margin: 0; font-size: 1.15rem; }
.wr-voice-head-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.wr-voice-status {
margin: 0.85rem 0 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: rgba(255,255,255,0.05);
font-size: 0.9rem;
min-height: 1.4em;
}
.wr-voice-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.65rem;
}
.wr-voice-card {
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
padding: 0.75rem 0.5rem;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.03);
text-align: center;
cursor: pointer;
transition: border-color .15s, box-shadow .15s, opacity .15s;
}
.wr-voice-card.is-human { cursor: default; border-color: rgba(56, 189, 248, 0.35); }
.wr-voice-card.is-live {
border-color: rgba(34, 197, 94, 0.7);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
}
.wr-voice-card.is-off { opacity: 0.45; }
.wr-voice-avatar {
width: 52px; height: 52px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
overflow: hidden; background: #1e293b;
}
.wr-voice-avatar-human {
font-weight: 700; font-size: 0.85rem; background: #0ea5e9; color: #041018;
}
.wr-voice-avatar .agent-char-wrap, .wr-voice-avatar.agent-char-wrap { width: 52px; height: 52px; }
.wr-voice-card strong { font-size: 0.82rem; }
.wr-voice-card small { font-size: 0.7rem; }
.wr-voice-hint { font-size: 0.8rem; opacity: 0.8; margin: 0.75rem 0 0.25rem; }
.wr-voice-ptt { display: flex; justify-content: center; margin: 1rem 0 0.5rem; }
.wr-ptt-btn {
min-width: min(320px, 90vw);
min-height: 64px;
border: 0; border-radius: 999px;
font-size: 1.05rem; font-weight: 600;
background: linear-gradient(135deg, #0ea5e9, #2563eb);
color: #fff; cursor: pointer;
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
}
.wr-ptt-btn.is-recording {
background: linear-gradient(135deg, #dc2626, #b91c1c);
animation: wr-mic-pulse 1s ease-in-out infinite;
}
.wr-ptt-btn.is-busy { opacity: 0.7; cursor: wait; }
.wr-voice-foot {
display: flex; justify-content: space-between; align-items: center; gap: 0.75rem;
flex-wrap: wrap; margin-top: 0.85rem; padding-top: 0.75rem;
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 0.78rem;
}
/* War Room roster / message avatars — brighter */
.wr-roster .agent-char-wrap,
.wr-avatar .agent-char-wrap {
background: linear-gradient(145deg, #245a82, #1a4568) !important;
border: 1px solid rgba(56, 189, 248, 0.4) !important;
border-radius: 10px;
overflow: hidden;
}
.wr-roster .agent-char-wrap .agent-char-svg,
.wr-avatar .agent-char-wrap .agent-char-svg {
filter: brightness(1.22) contrast(1.1) saturate(1.2);
}
.wr-chan-icon {
display: inline-flex;
width: 16px;
height: 16px;
color: #38bdf8;
margin-right: 0.35rem;
vertical-align: -2px;
}
.wr-chan-icon svg { width: 16px; height: 16px; display: block; }
/* Agents vertical tabs — SVG icons */
.vtabs-nav .vtab-icon-svg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
color: #7dd3fc;
background: color-mix(in srgb, #22d3ee 14%, rgba(15,23,42,.6));
border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent);
font-size: 0;
}
.vtabs-nav .vtab-icon-svg svg { width: 15px; height: 15px; display: block; }
.vtabs-nav .vtab-btn.active .vtab-icon-svg {
color: #fff;
background: color-mix(in srgb, #22d3ee 45%, #0b1220);
border-color: #22d3ee;
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35);
}
/* War Room richer toolbar / search / dates */
.wr-head-title { min-width: 0; }
.wr-msg-count { display: block; margin-top: 0.15rem; font-size: 0.7rem; opacity: 0.65; }
.wr-tool-btn.active {
border-color: rgba(34, 211, 238, 0.55) !important;
background: rgba(34, 211, 238, 0.15) !important;
}
.wr-tools-wrap { position: relative; }
.wr-tools-menu {
position: absolute; right: 0; top: calc(100% + 4px); z-index: 40;
min-width: 200px;
background: #0f2438;
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 10px;
padding: 0.35rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
display: flex; flex-direction: column; gap: 0.15rem;
}
.wr-tools-menu button {
text-align: left; background: transparent; border: 0; color: #e2e8f0;
padding: 0.45rem 0.55rem; border-radius: 7px; cursor: pointer; font-size: 0.8rem;
}
.wr-tools-menu button:hover { background: rgba(56, 189, 248, 0.12); }
.wr-tools-menu button.wr-danger { color: #fda4af; }
.wr-tools-menu button.wr-danger:hover { background: rgba(244, 63, 94, 0.15); }
.wr-tools-menu hr {
border: 0; border-top: 1px solid rgba(148,163,184,0.2); margin: 0.25rem 0;
}
.wr-searchbar {
display: flex; gap: 0.4rem; align-items: center;
margin-bottom: 0.4rem; flex: 0 0 auto;
}
.wr-searchbar .form-input { flex: 1; min-width: 0; font-size: 0.85rem; padding: 0.4rem 0.65rem; }
.wr-day-sep {
display: flex; align-items: center; justify-content: center;
margin: 0.75rem 0 0.55rem;
}
.wr-day-sep span {
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
padding: 0.2rem 0.7rem;
border-radius: 999px;
}
.wr-bubble-meta {
display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
}
.wr-who { font-weight: 600; color: #e2e8f0; opacity: 1; }
.wr-handle { opacity: 0.55; font-size: 0.65rem; }
.wr-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.55;
font-size: 0.65rem;
}
.wr-bubble.is-user .wr-time { margin-left: 0; margin-right: auto; order: -1; }
.wr-a2a-btn {
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
}
.wr-a2a-btn.is-on {
background: rgba(34, 197, 94, 0.18) !important;
border: 1px solid rgba(34, 197, 94, 0.45) !important;
color: #86efac !important;
}
.wr-a2a-btn.is-off {
background: rgba(148, 163, 184, 0.12) !important;
border: 1px solid rgba(148, 163, 184, 0.35) !important;
color: #94a3b8 !important;
}
.wr-mobile-bar { display: none; }
/* CEO inbox */
.wr-ceo-btn { position: relative; }
.wr-ceo-badge {
display: inline-grid; place-items: center;
min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem;
margin-left: 0.25rem; border-radius: 999px;
background: #f43f5e; color: #fff; font-size: 0.65rem; font-weight: 700;
}
.wr-ceo-overlay {
position: absolute; inset: 0; z-index: 80;
background: rgba(2, 8, 20, 0.72);
display: flex; align-items: stretch; justify-content: flex-end;
border-radius: 10px;
}
.wr-main { position: relative; }
.wr-ceo-panel {
width: min(420px, 100%);
background: linear-gradient(180deg, #0f2744, #0a1c33);
border-left: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: -12px 0 40px rgba(0,0,0,0.45);
display: flex; flex-direction: column;
padding: 0.75rem;
}
.wr-ceo-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.65rem; }
.wr-ceo-head h3 { margin: 0; font-size: 0.95rem; }
.wr-ceo-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: flex-start; }
.wr-ceo-list { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.wr-ceo-item {
border: 1px solid rgba(148,163,184,0.18);
background: rgba(15, 23, 42, 0.55);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.wr-ceo-item.unread {
border-color: rgba(251, 191, 36, 0.55);
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08);
}
.wr-ceo-item-meta {
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
font-size: 0.72rem; opacity: 0.85; margin-bottom: 0.35rem;
}
.wr-ceo-unread-dot {
background: #fbbf24; color: #111; font-weight: 700;
padding: 0.05rem 0.4rem; border-radius: 999px; font-size: 0.65rem;
}
.wr-ceo-item-body {
white-space: pre-wrap; word-break: break-word;
font-size: 0.86rem; line-height: 1.35;
}
.wr-ceo-item-actions { margin-top: 0.4rem; }
.wr-bubble.is-ceo-direct .wr-bubble-body {
border-color: rgba(251, 191, 36, 0.55) !important;
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08) !important;
}
/* Reply */
.wr-msg-reply {
margin-left: auto;
border: 0;
background: transparent;
color: inherit;
opacity: 0;
cursor: pointer;
font-size: 0.68rem;
padding: 0 0.2rem;
}
.wr-bubble:hover .wr-msg-reply { opacity: 0.7; }
.wr-msg-reply:hover { opacity: 1 !important; color: var(--accent, #3b82f6); }
.wr-reply-quote {
border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
padding: 0.2rem 0.45rem;
margin: 0.15rem 0 0.35rem;
font-size: 0.72rem;
opacity: 0.85;
background: color-mix(in srgb, var(--surface-2, #111) 60%, transparent);
border-radius: 0 6px 6px 0;
}
.wr-reply-quote-who { display: block; font-weight: 600; margin-bottom: 0.1rem; }
.wr-reply-quote-text { display: block; opacity: 0.85; white-space: pre-wrap; }
.wr-reply-bar {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
margin-bottom: 0.25rem;
border-radius: 8px;
border-left: 3px solid var(--accent, #3b82f6);
background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
font-size: 0.78rem;
}
.wr-reply-bar-text { flex: 1; min-width: 0; }
.wr-reply-bar-text strong { display: block; margin-bottom: 0.1rem; }
.wr-reply-bar-text span {
display: block;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid color-mix(in srgb, var(--accent, #38bdf8) 70%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, #38bdf8 18%, transparent);
}
.wr-msg-wrap { scroll-margin: 4rem; }
.wr-log-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.wr-log-wrap .wr-log {
flex: 1 1 auto;
min-height: 0;
}
.wr-jump-latest {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 25;
border: 1px solid rgba(56, 189, 248, 0.45);
background: rgba(8, 20, 36, 0.94);
color: #e0f2fe;
border-radius: 999px;
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
}
.wr-jump-latest:hover {
background: rgba(14, 40, 64, 0.98);
border-color: rgba(56, 189, 248, 0.7);
}
+326 -43
View File
@@ -1,50 +1,333 @@
.analytics-shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
/* Foodlinkk Intelligence Analytics — lush neo dashboard */
.ax-shell {
--ax-bg: #070b12;
--ax-panel: rgba(12, 18, 28, 0.88);
--ax-line: rgba(125, 211, 252, 0.16);
--ax-cyan: #38bdf8;
--ax-lime: #b8ff3c;
--ax-amber: #fbbf24;
--ax-rose: #fb7185;
--ax-text: #e8eef7;
--ax-muted: #8fa3b8;
--ax-font: "Outfit", "Segoe UI", sans-serif;
--ax-mono: "JetBrains Mono", ui-monospace, monospace;
font-family: var(--ax-font);
color: var(--ax-text);
margin: -0.25rem -0.5rem 0;
padding: 0 0 2.5rem;
position: relative;
}
.analytics-filters label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #94a3b8;
}
.analytics-filters select,
.analytics-filters input {
width: 100%;
margin-top: 0.25rem;
}
.analytics-main { min-width: 0; }
.analytics-kpis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
.ax-shell * { box-sizing: border-box; }
.ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; }
.ax-muted { color: var(--ax-muted); font-size: 0.82rem; }
.ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; }
.ax-hero {
position: relative;
overflow: hidden;
border-radius: 22px;
border: 1px solid var(--ax-line);
background:
radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%),
radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%),
linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%);
padding: 1.4rem 1.5rem 1.2rem;
margin-bottom: 1rem;
animation: ax-fade-up .55s ease both;
}
.analytics-kpis .kpi-card {
padding: 0.75rem 1rem;
border-radius: 10px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.15);
.ax-hero-glow {
position: absolute; inset: auto -20% -40% 40%; height: 70%;
background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%);
pointer-events: none; filter: blur(8px);
}
.analytics-kpis .kpi-card strong {
display: block;
font-size: 1.35rem;
color: #e2e8f0;
}
.analytics-kpis .kpi-card span {
font-size: 0.75rem;
color: #64748b;
}
.analytics-charts {
.ax-hero-grid {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
grid-template-columns: 1.4fr auto 1fr;
gap: 1.25rem;
align-items: center;
}
.chart-panel canvas { max-height: 220px; }
.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; }
@media (max-width: 900px) {
.analytics-shell { grid-template-columns: 1fr; }
.ax-eyebrow {
margin: 0 0 0.35rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.ax-hero h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.05;
}
.ax-live {
display: inline-flex; align-items: center;
margin-left: 0.55rem; vertical-align: middle;
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em;
padding: 0.22rem 0.55rem; border-radius: 999px;
background: rgba(184,255,60,0.12); border: 1px solid rgba(184,255,60,0.35);
color: #d9ff8b; animation: ax-pulse 2.4s ease-in-out infinite;
}
.ax-lead { margin: 0.65rem 0 0; max-width: 36rem; color: #b6c7d8; font-size: 1rem; line-height: 1.5; }
.ax-goal { margin: 0.55rem 0 0; color: #7dd3fc; font-size: 0.82rem; opacity: 0.9; }
.ax-pulse-orb { text-align: center; position: relative; width: 150px; justify-self: center; }
.ax-ring { width: 150px; height: 150px; transform: rotate(-90deg); }
.ax-ring-track { fill: none; stroke: rgba(148,163,184,0.15); stroke-width: 8; }
.ax-ring-value {
fill: none; stroke: var(--ax-cyan); stroke-width: 8; stroke-linecap: round;
transition: stroke-dasharray .8s ease;
filter: drop-shadow(0 0 8px rgba(56,189,248,0.45));
}
.tone-good .ax-ring-value { stroke: var(--ax-lime); filter: drop-shadow(0 0 8px rgba(184,255,60,0.4)); }
.tone-mid .ax-ring-value { stroke: var(--ax-amber); }
.tone-low .ax-ring-value { stroke: var(--ax-rose); }
.ax-pulse-core {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center;
transform: translateY(-6px);
}
.ax-pulse-core strong { font-size: 2.1rem; font-weight: 700; line-height: 1; }
.ax-pulse-core span { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ax-muted); }
.ax-pulse-label { margin: 0.15rem 0 0; font-size: 0.75rem; color: #cbd5e1; }
.ax-hero-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: flex-end; align-items: center; }
.ax-btn {
appearance: none; border: 1px solid rgba(148,163,184,0.28);
background: rgba(8,12,20,0.55); color: #e2e8f0;
border-radius: 999px; padding: 0.45rem 0.85rem; font: inherit; font-size: 0.78rem;
cursor: pointer; text-decoration: none; transition: border-color .15s, background .15s, transform .12s;
}
.ax-btn:hover { border-color: #38bdf8; background: rgba(14,165,233,0.12); transform: translateY(-1px); }
.ax-btn-primary {
border-color: rgba(184,255,60,0.45);
background: linear-gradient(135deg, rgba(184,255,60,0.18), rgba(56,189,248,0.12));
color: #f8ffe8;
}
.ax-updated { width: 100%; text-align: right; color: #64748b; font-size: 0.7rem; }
.ax-filters {
margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid rgba(148,163,184,0.12);
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.65rem; align-items: end;
}
.ax-filters label { font-size: 0.72rem; color: #94a3b8; display: grid; gap: 0.25rem; }
.ax-filters select, .ax-filters input {
width: 100%; border-radius: 8px; border: 1px solid rgba(148,163,184,0.25);
background: #0a1018; color: #e2e8f0; padding: 0.4rem 0.5rem; font: inherit;
}
.ax-signals {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
animation: ax-fade-up .6s ease both .08s;
}
.ax-signal {
position: relative; overflow: hidden;
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 16px;
padding: 0.85rem 0.9rem 0.75rem;
min-height: 96px;
}
.ax-signal::after {
content: ""; position: absolute; inset: auto 0 0 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--ax-cyan), transparent);
opacity: 0.55;
}
.ax-signal.good::after { background: linear-gradient(90deg, transparent, var(--ax-lime), transparent); }
.ax-signal.warn::after { background: linear-gradient(90deg, transparent, var(--ax-amber), transparent); }
.ax-signal-label { display: block; font-size: 0.68rem; color: #94a3b8; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-signal strong { display: block; margin-top: 0.35rem; font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; }
.ax-signal em { display: block; margin-top: 0.25rem; font-style: normal; font-size: 0.72rem; color: #64748b; }
.ax-split {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem;
animation: ax-fade-up .65s ease both .12s;
}
.ax-col {
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 18px;
padding: 1rem;
min-height: 320px;
}
.ax-col-win { box-shadow: inset 3px 0 0 rgba(184,255,60,0.55); }
.ax-col-opp { box-shadow: inset 3px 0 0 rgba(251,191,36,0.55); }
.ax-col-head {
display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem;
margin-bottom: 0.85rem;
}
.ax-col-head h2, .ax-hermes h2, .ax-radar h2 {
margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
}
.ax-col-head span, .ax-pill {
font-size: 0.72rem; color: #94a3b8;
padding: 0.2rem 0.55rem; border-radius: 999px;
border: 1px solid rgba(148,163,184,0.2);
}
.ax-insight {
display: block; text-decoration: none; color: inherit;
border: 1px solid rgba(148,163,184,0.12);
background: rgba(6, 10, 16, 0.55);
border-radius: 14px;
padding: 0.8rem 0.85rem;
margin-bottom: 0.55rem;
transition: transform .14s, border-color .14s, background .14s;
}
.ax-insight:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.35);
background: rgba(14,165,233,0.08);
}
.ax-insight-top { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.ax-insight-top strong { font-size: 0.95rem; line-height: 1.3; }
.ax-score {
font-size: 0.85rem; color: #d9ff8b; min-width: 2rem; text-align: right;
}
.ax-insight.opp .ax-score { color: #fcd34d; }
.ax-insight p { margin: 0.4rem 0 0.55rem; font-size: 0.82rem; color: #a7b7c8; line-height: 1.45; }
.ax-insight-foot { display: grid; gap: 0.35rem; }
.ax-insight-foot > span:first-child { font-size: 0.72rem; color: #7dd3fc; }
.ax-bar, .ax-mini-bar {
display: block; height: 4px; border-radius: 999px; background: rgba(148,163,184,0.15); overflow: hidden;
}
.ax-bar i, .ax-mini-bar i {
display: block; height: 100%; border-radius: inherit;
background: linear-gradient(90deg, #84cc16, #b8ff3c);
}
.ax-bar.opp i { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.ax-tag {
display: inline-block; font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.4rem; border-radius: 999px; margin-right: 0.25rem;
background: rgba(251,191,36,0.12); color: #fcd34d; border: 1px solid rgba(251,191,36,0.25);
}
.ax-empty { color: #64748b; font-size: 0.85rem; padding: 1rem 0; }
.ax-band { margin-bottom: 1rem; animation: ax-fade-up .7s ease both .16s; }
.ax-att-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.ax-att-chip {
flex: 1 1 220px; text-decoration: none; color: inherit;
border: 1px solid rgba(251,113,133,0.28);
background: rgba(251,113,133,0.08);
border-radius: 12px; padding: 0.7rem 0.8rem;
}
.ax-att-chip strong { display: block; font-size: 0.85rem; margin-bottom: 0.2rem; }
.ax-att-chip span { font-size: 0.75rem; color: #cbd5e1; }
.ax-attention h3 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #fda4af; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-hermes {
border: 1px solid var(--ax-line);
border-radius: 18px;
background:
radial-gradient(700px 220px at 0% 0%, rgba(56,189,248,0.12), transparent 55%),
var(--ax-panel);
padding: 1rem 1.1rem 1.1rem;
}
.ax-findings-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.65rem; margin-top: 0.75rem;
}
.ax-finding {
border-radius: 14px; border: 1px solid rgba(148,163,184,0.16);
background: rgba(5,9,14,0.65); padding: 0.85rem;
}
.ax-finding.kind-win { border-color: rgba(184,255,60,0.3); }
.ax-finding.kind-opportunity { border-color: rgba(251,191,36,0.35); }
.ax-finding-top { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
.ax-finding-top time { font-size: 0.68rem; color: #64748b; }
.ax-finding h4 { margin: 0; font-size: 0.95rem; }
.ax-finding p { margin: 0.4rem 0 0.55rem; font-size: 0.8rem; color: #a7b7c8; line-height: 1.45; }
.ax-finding-foot { display: flex; justify-content: space-between; font-size: 0.7rem; color: #64748b; }
.ax-findings-empty { margin-top: 0.75rem; }
.ax-findings-placeholder {
border: 1px dashed rgba(56,189,248,0.35);
border-radius: 14px; padding: 1.1rem 1.2rem;
background: rgba(14,165,233,0.05);
}
.ax-findings-placeholder strong { display: block; margin-bottom: 0.3rem; }
.ax-findings-placeholder p { margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
.ax-radar {
border: 1px solid var(--ax-line);
border-radius: 18px;
background: var(--ax-panel);
padding: 1rem 1.1rem 1.15rem;
margin-bottom: 1rem;
animation: ax-fade-up .75s ease both .2s;
}
.ax-charts {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
}
.ax-chart-card {
grid-column: span 2;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
background: rgba(5,9,14,0.55);
padding: 0.75rem 0.8rem 0.6rem;
}
.ax-chart-card.wide { grid-column: span 6; }
.ax-chart-card h3 { margin: 0 0 0.45rem; font-size: 0.82rem; color: #cbd5e1; font-weight: 600; }
.ax-chart-wrap { position: relative; height: 210px; }
.ax-chart-card.wide .ax-chart-wrap { height: 180px; }
.ax-lists {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.85rem;
animation: ax-fade-up .8s ease both .24s;
}
.ax-list-card {
border: 1px solid var(--ax-line);
border-radius: 16px;
background: var(--ax-panel);
padding: 0.9rem 1rem;
}
.ax-list-card h3 { margin: 0 0 0.75rem; font-size: 0.92rem; }
.ax-list-card ul { list-style: none; margin: 0; padding: 0; }
.ax-list-card li {
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
padding: 0.55rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-rank {
width: 1.5rem; height: 1.5rem; border-radius: 8px;
display: grid; place-items: center; font-size: 0.72rem;
background: rgba(56,189,248,0.12); color: #7dd3fc;
}
.ax-list-card li strong { display: block; font-size: 0.82rem; }
.ax-list-card li em { font-style: normal; color: #fbbf24; font-size: 0.85rem; }
.ax-events li { grid-template-columns: 1fr auto; }
.ax-events li span { display: block; font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
.ax-events time { font-size: 0.68rem; color: #64748b; }
.ax-dl { margin: 0; display: grid; gap: 0.55rem; }
.ax-dl > div {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.45rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-dl dt { color: #94a3b8; font-size: 0.8rem; }
.ax-dl dd { margin: 0; font-size: 0.9rem; color: #e2e8f0; }
@keyframes ax-fade-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes ax-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(184,255,60,0.0); }
50% { box-shadow: 0 0 0 6px rgba(184,255,60,0.08); }
}
@media (max-width: 1100px) {
.ax-hero-grid { grid-template-columns: 1fr; }
.ax-hero-actions { justify-content: flex-start; }
.ax-signals { grid-template-columns: repeat(3, 1fr); }
.ax-split, .ax-lists { grid-template-columns: 1fr; }
.ax-charts { grid-template-columns: 1fr 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 2; }
}
@media (max-width: 700px) {
.ax-signals { grid-template-columns: 1fr 1fr; }
.ax-charts { grid-template-columns: 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 1; }
}
+95 -20
View File
@@ -1,24 +1,38 @@
:root { --sidebar-width: 280px; }
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
display: block;
min-height: 100vh;
}
.app-sidebar {
position: sticky;
position: fixed;
top: 0;
align-self: start;
height: auto;
max-height: none;
overflow: visible;
overflow-x: hidden;
padding: 0.75rem 0.6rem;
left: 0;
bottom: 0;
width: var(--sidebar-width, 280px);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0.75rem 0.6rem 0;
background: #080c12;
border-right: 1px solid rgba(148, 163, 184, 0.1);
}
.app-main {
margin-left: var(--sidebar-width, 280px);
min-width: 0;
min-height: 100vh;
}
.app-sidebar-brand,
.persona-switch.brand-switch {
flex-shrink: 0;
}
.app-sidebar-brand {
padding: 0.25rem 0.5rem 0.75rem;
margin-bottom: 0.5rem;
@@ -59,7 +73,16 @@
.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; }
.persona-nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.75rem;
scrollbar-gutter: stable;
}
.app-sidebar-group {
margin-bottom: 0.65rem;
@@ -120,29 +143,46 @@
animation: none !important;
}
/* SVG line icons in sidebar */
.sidebar-nav-icon {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.95rem;
background: rgba(8, 12, 18, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
font-size: 0;
color: var(--nav-accent, #7dd3fc);
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 14%, rgba(255,255,255,.06));
border: 1px solid color-mix(in srgb, var(--nav-accent, #22d3ee) 35%, transparent);
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
}
.sidebar-nav-icon svg {
width: 16px;
height: 16px;
display: block;
}
.app-sidebar a.nav-pill:hover .sidebar-nav-icon {
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 24%, rgba(255,255,255,.08));
border-color: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, transparent);
}
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
color: #fff;
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, #0b1220);
border-color: var(--nav-accent, #22d3ee);
box-shadow: 0 0 12px color-mix(in srgb, var(--nav-accent, #22d3ee) 45%, transparent);
}
.sidebar-nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.86rem;
}
/* Per-item accent colors */
@@ -178,3 +218,38 @@
}
.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
/* sidebar resize v11 */
.app-sidebar-resizer {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 5;
background: transparent;
}
.app-sidebar-resizer:hover,
.app-sidebar-resizer.is-dragging {
background: rgba(56, 189, 248, 0.35);
}
body.sidebar-resizing {
cursor: col-resize !important;
user-select: none !important;
}
body.sidebar-resizing iframe {
pointer-events: none !important;
}
@media (max-width: 768px) {
.app-sidebar-resizer { display: none !important; }
.app-main { margin-left: 0 !important; }
.app-sidebar {
z-index: 1000;
overflow-y: auto;
overflow-x: hidden;
}
}
+135 -8
View File
@@ -1,19 +1,146 @@
/* Wereldexport — KPIs + layout lock */
/* Wereldexport — full-width map workspace */
.wereldexport-workspace.vtabs-layout {
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
gap: 1rem !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 0.65rem !important;
width: 100%;
min-height: calc(100dvh - 220px);
}
.wereldexport-workspace > .vtabs-nav {
flex: 0 0 220px !important;
width: 220px !important;
max-width: 220px !important;
flex: 0 0 auto !important;
width: 100% !important;
max-width: none !important;
position: sticky;
top: 0;
z-index: 60;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 0.35rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0.65rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.wereldexport-workspace > .vtabs-nav::before { display: none; }
.wereldexport-workspace .vtabs-nav-header { display: none; }
.wereldexport-workspace > .vtabs-content {
flex: 1 1 0 !important;
flex: 1 1 auto !important;
min-width: 0 !important;
min-height: 0 !important;
display: flex;
flex-direction: column;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
flex: 0 0 auto;
min-width: 128px;
max-width: 168px;
grid-template-columns: auto 1fr;
padding: 0.45rem 0.55rem;
}
.wereldexport-workspace .ei-vtab-meta,
.wereldexport-workspace .ei-vtab-key,
.wereldexport-workspace .ei-vtab-sub { display: none; }
.wereldexport-workspace .vtab-btn.active {
box-shadow: inset 0 -2px 0 var(--tab-accent, #0ea5e9);
}
.ei-tab-panel:has(.ei-workspace-body) {
background: rgba(8, 11, 16, 0.95);
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 280px);
border-radius: 12px;
overflow: hidden;
}
.ei-tab-panel:has(.ei-workspace-body-map) .ei-tab-panel-head {
display: none;
}
.ei-workspace-body,
.ei-workspace-body-map {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0 !important;
}
.ei-workspace-body-map .ei-map-legend {
flex-shrink: 0;
margin: 0;
padding: 0.45rem 0.75rem;
font-size: 0.68rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.ei-workspace-body-map .ei-map-empty {
flex-shrink: 0;
margin: 0;
padding: 0 0.75rem 0.45rem;
}
.ei-workspace-body-map .ei-map-empty-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.55rem 0.75rem;
}
.ei-workspace-body-map .ei-map-empty-inner strong {
display: inline;
margin: 0;
font-size: 0.78rem;
}
.ei-workspace-body-map .ei-map-empty-inner p {
flex: 1 1 200px;
margin: 0;
font-size: 0.72rem;
}
.ei-workspace-body-map .ei-map-empty-actions {
margin: 0;
flex-shrink: 0;
}
.ei-workspace-body-map .ei-map-truncated,
.ei-workspace-body-map .ei-map-error {
flex-shrink: 0;
margin: 0 0.75rem 0.35rem;
}
.ei-workspace-body-map > .ei-split.ei-split-side {
flex: 1 1 auto;
min-height: 420px;
}
@media (min-width: 900px) {
.ei-workspace-body-map > .ei-split.ei-split-side {
min-height: calc(100dvh - 320px);
}
}
@media (max-width: 899px) {
.wereldexport-workspace > .vtabs-nav {
flex-wrap: nowrap;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
min-width: 110px;
}
}
.ei-neo-kpi-row {
+684 -32
View File
@@ -13,33 +13,102 @@
.ei-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 1rem;
flex-direction: column;
gap: 0.65rem;
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-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 0.55rem 0.65rem;
align-items: end;
}
.ei-filter-cell { min-width: 0; }
.ei-filter-cell-search { grid-column: span 3; }
.ei-filter-cell-check {
grid-column: span 2;
align-self: end;
padding-bottom: 0.45rem;
}
.ei-filter-cell-meta {
grid-column: span 3;
justify-self: end;
}
.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 {
.ei-filter-label input[type="search"],
.ei-filter-label input[type="text"] {
width: 100%;
box-sizing: border-box;
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-meta {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
padding-bottom: 0.35rem;
}
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
.ei-filter-map-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem 0.85rem;
padding-top: 0.15rem;
border-top: 1px solid rgba(148,163,184,0.1);
}
.ei-filter-map-row-halal { border-top: none; padding-top: 0; }
.ei-filter-map-label { margin: 0; white-space: nowrap; }
@media (min-width: 1100px) {
.ei-filter-grid {
grid-template-areas: "regio land zoek type contact conf stad crm fav meta meta meta";
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.ei-fa-regio { grid-area: regio; }
.ei-fa-land { grid-area: land; }
.ei-fa-zoek { grid-area: zoek; }
.ei-fa-type { grid-area: type; }
.ei-fa-contact { grid-area: contact; }
.ei-fa-conf { grid-area: conf; }
.ei-fa-stad { grid-area: stad; }
.ei-fa-extra { grid-area: stad; }
.ei-fa-crm { grid-area: crm; }
.ei-fa-fav { grid-area: fav; }
.ei-fa-meta { grid-area: meta; }
.ei-filter-cell-search,
.ei-filter-cell-check,
.ei-filter-cell-meta { grid-column: auto; }
}
@media (max-width: 1099px) {
.ei-filter-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ei-filter-cell-search { grid-column: span 2; }
.ei-filter-cell-meta { grid-column: 1 / -1; justify-self: end; }
}
@media (max-width: 640px) {
.ei-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ei-filter-cell-search { grid-column: 1 / -1; }
}
.ei-split { align-items: stretch; }
.ei-split > .ei-map-wrap { width: 100%; }
@@ -93,21 +162,106 @@
.ei-split {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0;
min-height: 0;
}
@media (min-width: 1200px) {
@media (min-width: 900px) {
.ei-split.ei-split-side {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr);
grid-template-rows: minmax(480px, 62vh);
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 400px);
grid-template-rows: minmax(0, 1fr);
align-items: stretch;
height: 100%;
min-height: 420px;
}
.ei-split.ei-split-side > .ei-map-wrap {
grid-column: 1;
grid-row: 1;
height: 100%;
min-height: 0;
}
.ei-split.ei-split-side > .ei-side-panel {
grid-column: 2;
grid-row: 1;
height: 100%;
min-height: 0;
}
.ei-split.ei-split-side > .ei-map-wrap { grid-column: 1; grid-row: 1; height: 100%; min-height: 480px; }
.ei-split.ei-split-side > .ei-panel { grid-column: 2; grid-row: 1; max-height: 62vh; overflow: auto; }
}
.ei-map-wrap { position: relative; }
.ei-workspace-body {
padding: 0 !important;
}
.ei-workspace-body-map {
padding: 0 1rem 1rem !important;
}
.ei-workspace-body-map .ei-map-legend,
.ei-workspace-body-map .ei-map-empty,
.ei-workspace-body-map .ei-map-error,
.ei-workspace-body-map .ei-map-truncated {
margin-left: 0;
margin-right: 0;
}
.ei-workspace-body > .ei-split {
border-radius: 0 0 12px 12px;
overflow: hidden;
}
.ei-side-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: linear-gradient(180deg, rgba(12, 16, 22, 0.98) 0%, rgba(8, 11, 16, 0.98) 100%);
border-left: 1px solid rgba(148, 163, 184, 0.1);
}
.ei-side-panel-head {
flex-shrink: 0;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(15, 23, 42, 0.35);
}
.ei-side-panel-head h3 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: #f1f5f9;
}
.ei-side-panel-head p {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #64748b;
line-height: 1.35;
}
.ei-side-panel-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0;
}
.ei-side-section {
padding: 0.65rem 0.75rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.ei-side-section h4 {
margin: 0 0 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.ei-side-hint {
margin: 0;
padding: 0.65rem 0.85rem;
font-size: 0.78rem;
color: #64748b;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(14, 165, 233, 0.04);
}
.ei-action-tile[disabled], .ei-action-tile.ei-action-busy { opacity: 0.55; pointer-events: none; }
.ei-map-loading {
position: absolute; inset: 0; z-index: 500;
display: flex; align-items: center; justify-content: center; gap: 0.65rem;
@@ -125,17 +279,37 @@
.ei-map-error { margin: 0 0 0.65rem; padding: 0.65rem 0.85rem; border-radius: 10px; border: 1px solid rgba(251,113,133,0.35); background: rgba(127,29,29,0.25); color: #fecdd3; font-size: 0.82rem; }
.ei-map-wrap {
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(14,165,233,0.2);
background: #070b10;
}
.ei-workspace-body .ei-map-wrap {
border: none;
border-radius: 0;
}
.ei-workspace-body-map .ei-split-side > .ei-map-wrap {
border: 1px solid rgba(14, 165, 233, 0.18);
border-radius: 12px;
}
.ei-split:not(.ei-split-side) > .ei-map-wrap {
height: min(62vh, 720px);
min-height: 480px;
background: #0a0e14;
border-radius: 12px;
border: 1px solid rgba(14, 165, 233, 0.18);
}
.ei-map-wrap #ei-map {
.ei-map-wrap #ei-map,
.ei-map-wrap [id^="ei-map-"] {
height: 100%;
min-height: 480px;
width: 100%;
min-height: 280px;
}
@media (max-width: 899px) {
.ei-split > .ei-map-wrap {
height: min(48vh, 440px);
min-height: 280px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.ei-side-panel { border-left: none; border-top: 1px solid rgba(148, 163, 184, 0.1); }
.ei-side-panel-body { max-height: 50vh; }
}
/* Leaflet popups — rich card */
@@ -204,7 +378,6 @@
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;
@@ -228,12 +401,6 @@
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;
@@ -354,10 +521,10 @@
.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-root { position: fixed; inset: 0; z-index: 10050; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.ei-modal-card {
position: relative;
z-index: 1302;
z-index: 10051;
width: min(420px, 96vw);
padding: 1.25rem;
border-radius: 14px;
@@ -439,7 +606,7 @@
.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-rich { width: min(640px, 94vw); 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; }
@@ -464,3 +631,488 @@
.ei-link-btn:hover { text-decoration: underline; }
.ei-td-actions { white-space: nowrap; }
.ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; }
/* Entity rows — click to open full profile */
.ei-th-open, .ei-td-open { width: 88px; text-align: right; }
.ei-open-chevron {
color: #38bdf8;
font-size: 0.8125rem;
font-weight: 600;
opacity: 0.75;
transition: opacity 0.15s, transform 0.15s;
}
.ei-table tbody tr:hover .ei-open-chevron { opacity: 1; transform: translateX(2px); }
.ei-table tbody tr { transition: background 0.12s; }
.ei-table tbody tr:hover { background: rgba(14, 165, 233, 0.06) !important; }
.ei-row-active { box-shadow: inset 3px 0 0 #0ea5e9; background: rgba(14, 165, 233, 0.1) !important; }
/* Full profile drawer */
.ei-drawer-rich {
width: min(640px, 94vw) !important;
display: flex;
flex-direction: column;
padding: 0;
}
.ei-drawer-head-rich {
padding: 1.1rem 1.25rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: linear-gradient(180deg, rgba(14, 165, 233, 0.08) 0%, transparent 100%);
flex-shrink: 0;
}
.ei-drawer-head-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.ei-drawer-close {
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.8);
color: #94a3b8;
border-radius: 8px;
width: 36px;
height: 36px;
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
}
.ei-drawer-close:hover { color: #fff; border-color: rgba(56, 189, 248, 0.4); }
.ei-drawer-loading {
display: flex; align-items: center; justify-content: center; gap: 0.65rem;
padding: 3rem 1rem; color: #94a3b8;
}
.ei-drawer-toolbar {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.55rem;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(8, 12, 18, 0.5);
flex-shrink: 0;
}
.ei-action-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.7);
color: #e2e8f0;
text-decoration: none;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.12s;
min-height: 72px;
}
.ei-action-tile:hover { border-color: rgba(56, 189, 248, 0.35); background: rgba(14, 165, 233, 0.08); transform: translateY(-1px); }
.ei-action-tile:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.ei-action-icon { font-size: 1.1rem; line-height: 1; }
.ei-action-label { font-size: 0.875rem; font-weight: 600; color: #f1f5f9; }
.ei-action-sub { font-size: 0.75rem; color: #64748b; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; max-width: 100%; white-space: nowrap; }
.ei-action-crm { border-color: rgba(74, 222, 128, 0.35); background: rgba(74, 222, 128, 0.08); }
.ei-action-crm:hover { border-color: rgba(74, 222, 128, 0.55); background: rgba(74, 222, 128, 0.12); }
.ei-action-crm-done { border-color: rgba(74, 222, 128, 0.4); background: rgba(74, 222, 128, 0.1); }
.ei-action-edit { border-color: rgba(56, 189, 248, 0.3); }
.ei-action-fav-on { border-color: rgba(251, 191, 36, 0.4); background: rgba(251, 191, 36, 0.08); }
.ei-drawer-body-rich { padding: 1rem 1.25rem 1.5rem; }
.ei-drawer-section {
background: rgba(10, 14, 20, 0.6);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 0.75rem;
}
.ei-drawer-section h4 { font-size: 0.8125rem !important; color: #94a3b8 !important; margin-bottom: 0.65rem !important; }
.ei-drawer-edit {
margin: 0 1.25rem 1rem;
border-radius: 12px;
}
.ei-drawer-title { font-size: 1.35rem !important; margin-top: 0.5rem; }
.ei-drawer-meta { font-size: 0.875rem !important; margin-bottom: 0 !important; }
.ei-drawer-kv { font-size: 0.9375rem !important; }
/* Profile modal — visibility via .is-open (not Alpine x-show) */
#ei-profile-modal { display: none !important; }
#ei-profile-modal.is-open {
display: flex !important;
z-index: 99999 !important;
}
/* Profiel popup modal */
body.ei-profile-open { overflow: hidden; }
.ei-profile-modal-root {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.ei-profile-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.68);
backdrop-filter: blur(4px);
animation: ei-fade-in 0.18s ease-out;
}
.ei-profile-panel {
position: relative;
z-index: 1;
width: min(720px, 96vw);
max-height: min(90vh, 920px);
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(14, 165, 233, 0.28);
background: linear-gradient(180deg, #121820 0%, #0c1016 100%);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(56, 189, 248, 0.08);
overflow: hidden;
animation: ei-modal-pop 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ei-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ei-modal-pop {
from { opacity: 0; transform: translateY(12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.ei-profile-inner { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow: hidden; }
.ei-drawer-head-rich { flex-shrink: 0; }
.ei-drawer-toolbar { flex-shrink: 0; max-height: 38vh; overflow-y: auto; }
.ei-drawer-body-rich { flex: 1; overflow-y: auto; }
@media (max-width: 640px) {
.ei-profile-panel { width: 100%; max-height: 96vh; border-radius: 14px 14px 0 0; }
.ei-profile-modal-root { padding: 0; align-items: flex-end; }
}
.ei-row-open-btn {
background: none; border: none; padding: 0; margin: 0;
color: #f1f5f9; font: inherit; font-weight: 600; text-align: left;
cursor: pointer; max-width: 100%;
}
.ei-row-open-btn:hover { color: #38bdf8; text-decoration: underline; }
#ei-profile-mount { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow: hidden; max-height: min(90vh, 920px); }
#ei-profile-mount .ei-drawer-body-rich { flex: 1; overflow-y: auto; }
.ei-filter-bar {
position: sticky;
top: 0;
z-index: 200;
backdrop-filter: blur(8px);
background: rgba(8, 12, 18, 0.92);
}
.ei-tab-filters {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(10, 14, 20, 0.6);
}
.ei-tab-filters-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 0.85rem;
align-items: flex-end;
}
.ei-tab-filter-search { flex: 1 1 180px; min-width: 160px; }
.ei-tab-filter-search input { width: 100%; }
.ei-tab-filter-count {
margin-left: auto;
font-size: 0.8125rem;
color: #64748b;
white-space: nowrap;
padding-bottom: 0.35rem;
}
@media (max-width: 768px) {
.ei-tab-filters-row { flex-direction: column; align-items: stretch; }
.ei-tab-filter-count { margin-left: 0; }
}
/* ── Side panel entity list — readable, scrolls inside panel ── */
.ei-side-panel-body {
display: flex;
flex-direction: column;
overflow: hidden;
}
.ei-side-panel .ei-table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ei-table-compact {
table-layout: fixed;
width: 100%;
min-width: 0;
border-collapse: collapse;
}
.ei-table-compact th,
.ei-table-compact td {
vertical-align: middle;
padding: 0.38rem 0.4rem;
line-height: 1.35;
}
.ei-table-compact thead th {
position: sticky;
top: 0;
z-index: 3;
background: #0f1419;
font-size: 0.65rem;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.12);
color: #64748b;
}
.ei-table-compact tbody tr {
height: auto;
min-height: 2.35rem;
}
.ei-table-compact .ei-th-check,
.ei-table-compact .ei-td-check {
width: 28px;
padding: 0.2rem 0.15rem;
text-align: center;
}
.ei-table-compact .ei-th-fav,
.ei-table-compact .ei-td-fav {
width: 30px;
padding: 0.2rem 0.15rem;
text-align: center;
}
.ei-table-compact .ei-td-check input[type="checkbox"] { margin: 0; }
.ei-table-compact .ei-th-name,
.ei-table-compact .ei-td-name {
width: 46%;
min-width: 0;
overflow: hidden;
}
.ei-name-cell {
display: flex;
align-items: flex-start;
gap: 0.35rem;
min-width: 0;
}
.ei-table-compact .ei-th-type,
.ei-table-compact .ei-td-type {
width: 22%;
min-width: 72px;
text-align: left;
}
.ei-table-compact .ei-th-city,
.ei-table-compact .ei-td-city {
width: 32%;
min-width: 88px;
font-size: 0.72rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide extra columns in side panel — details in profiel popup */
.ei-table-compact .ei-td-contact,
.ei-table-compact .ei-th-contact,
.ei-table-compact .ei-td-country,
.ei-table-compact .ei-th-country,
.ei-table-compact .ei-td-conf,
.ei-table-compact .ei-th-conf,
.ei-table-compact .ei-td-open,
.ei-table-compact .ei-th-open,
.ei-table-compact .ei-col-hide-side {
display: none !important;
}
.ei-table-compact .ei-td-score,
.ei-table-compact .ei-th-score {
width: 44px;
text-align: center;
font-size: 0.72rem;
}
.ei-table-compact .ei-type-pill,
.ei-table-compact .ei-type-pill-sm {
display: inline-block;
white-space: nowrap;
font-size: 0.62rem;
padding: 0.12rem 0.38rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.ei-table-compact .ei-row-open-btn {
flex: 1 1 auto;
min-width: 0;
text-align: left;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
line-height: 1.25;
font-size: 0.78rem;
font-weight: 600;
color: #f1f5f9;
}
.ei-table-compact .ei-crm-badge {
flex-shrink: 0;
margin: 0.1rem 0 0;
font-size: 0.55rem;
}
.ei-table-compact .ei-fav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
font-size: 0.9rem;
}
.ei-table-compact tbody tr { cursor: pointer; }
.ei-table-compact tbody tr:hover .ei-row-open-btn { color: #38bdf8; }
.ei-table-compact .ei-td-pipe-meta,
.ei-table-compact .ei-th-pipe-meta { display: none; }
.ei-table-compact:not(.ei-table-contacts) .ei-td-actions,
.ei-table-compact:not(.ei-table-contacts) th:empty:last-child { display: none; }
.ei-table-compact .ei-td-actions {
width: 68px;
padding: 0.2rem;
vertical-align: middle;
}
.ei-table-compact .ei-td-actions .btn { font-size: 0.6rem; padding: 0.18rem 0.32rem; }
.ei-table-brand .ei-td-name { width: 42%; }
.ei-table-brand .ei-td-country { width: 48px; }
@media (min-width: 1400px) {
.ei-split.ei-split-side {
grid-template-columns: minmax(0, 1.15fr) minmax(440px, 560px);
}
.ei-table-compact.ei-table-pipeline .ei-td-pipe-meta,
.ei-table-compact.ei-table-pipeline .ei-th-pipe-meta {
display: table-cell;
font-size: 0.68rem;
}
}
@media (max-width: 899px) {
.ei-split.ei-split-side {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.ei-side-panel-body { max-height: 50vh; }
}
.ei-popup-web-list { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ei-popup-web {
display: inline-block;
max-width: 100%;
padding: 0.12rem 0.4rem;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 600;
color: #7dd3fc !important;
background: rgba(14, 165, 233, 0.1);
border: 1px solid rgba(56, 189, 248, 0.22);
text-decoration: none !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ei-popup-web:hover { background: rgba(14, 165, 233, 0.18); color: #bae6fd !important; }
.ei-popup-websites .ei-popup-v { max-width: 200px; }
.ei-popup-card { max-height: 420px; overflow-y: auto; }
.ei-popup-meta { font-size: 0.72rem; color: #64748b; margin: 0.15rem 0 0.45rem; }
.ei-popup-reach { display: flex; flex-direction: column; gap: 0.45rem; margin: 0.35rem 0 0.5rem; }
.ei-popup-section { border-top: 1px solid rgba(148, 163, 184, 0.1); padding-top: 0.35rem; }
.ei-popup-section-title { font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; margin-bottom: 0.25rem; font-weight: 700; }
.ei-popup-channel-list, .ei-popup-web-list { display: flex; flex-direction: column; gap: 0.2rem; }
.ei-popup-link {
display: flex; flex-direction: column; gap: 0.05rem;
padding: 0.22rem 0.35rem; border-radius: 6px;
background: rgba(15, 23, 42, 0.55); border: 1px solid rgba(148, 163, 184, 0.12);
text-decoration: none !important; color: #e2e8f0 !important;
}
.ei-popup-link:hover { border-color: rgba(56, 189, 248, 0.35); background: rgba(14, 165, 233, 0.08); }
.ei-popup-link-label { font-size: 0.58rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.ei-popup-link-val { font-size: 0.72rem; color: #7dd3fc; word-break: break-all; line-height: 1.25; }
.ei-popup-plain { padding: 0.15rem 0.35rem; font-size: 0.72rem; color: #cbd5e1; }
.ei-popup-person { padding: 0.35rem 0.4rem; margin-top: 0.2rem; border-radius: 8px; background: rgba(8, 12, 18, 0.55); border: 1px solid rgba(148, 163, 184, 0.1); }
.ei-popup-person-head { font-size: 0.75rem; font-weight: 600; color: #f1f5f9; }
.ei-popup-person-sub { font-size: 0.65rem; color: #64748b; margin: 0.1rem 0 0.25rem; }
.ei-popup-person-links { display: flex; flex-direction: column; gap: 0.15rem; }
.ei-popup-primary { font-size: 0.55rem; color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.35); padding: 0 0.25rem; border-radius: 4px; margin-left: 0.2rem; }
.ei-popup-maps { display: inline-block; font-size: 0.72rem; color: #7dd3fc !important; text-decoration: none !important; }
.ei-popup-empty-contact { font-size: 0.72rem; color: #64748b; padding: 0.35rem 0; font-style: italic; }
.ei-reach-block h5 { margin: 0.5rem 0 0.25rem; font-size: 0.68rem; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
.leaflet-popup-content { margin: 10px 12px !important; }
.leaflet-popup-content-wrapper { border-radius: 12px !important; }
.ei-chip-count { font-size: 0.62rem; opacity: 0.75; margin-left: 0.15rem; }
.ei-type-chip.active .ei-chip-count { opacity: 1; }
.ei-map-filter-hint { color: #fbbf24 !important; }
.ei-sync-monitor {
margin: 0.65rem 0 0.35rem;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 10px;
background: rgba(15, 23, 42, 0.65);
}
.ei-sync-monitor-active { border-color: rgba(34, 197, 94, 0.45); box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.15); }
.ei-sync-monitor-toggle {
width: 100%; display: flex; align-items: center; gap: 0.6rem;
padding: 0.55rem 0.75rem; background: transparent; border: 0; color: #e2e8f0;
cursor: pointer; font-size: 0.82rem; text-align: left;
}
.ei-sync-monitor-badge {
margin-left: auto; font-size: 0.72rem; color: #7dd3fc;
background: rgba(14, 165, 233, 0.15); padding: 0.15rem 0.45rem; border-radius: 999px;
}
.ei-sync-monitor-panel { padding: 0 0.75rem 0.75rem; }
.ei-sync-monitor-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
.ei-sync-live { color: #4ade80; font-size: 0.72rem; animation: ei-pulse 1.5s ease-in-out infinite; }
@keyframes ei-pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
.ei-sync-job { margin-bottom: 0.65rem; padding: 0.55rem 0.65rem; border-radius: 8px; background: rgba(2, 6, 23, 0.55); }
.ei-sync-job-title { font-size: 0.78rem; font-weight: 600; color: #f8fafc; }
.ei-sync-job-msg { font-size: 0.74rem; color: #94a3b8; margin-top: 0.2rem; }
.ei-sync-job-bar { height: 6px; border-radius: 999px; background: rgba(51, 65, 85, 0.8); margin-top: 0.45rem; overflow: hidden; }
.ei-sync-job-bar-fill { height: 100%; background: linear-gradient(90deg, #0ea5e9, #22c55e); transition: width 0.4s ease; }
.ei-sync-job-meta { font-size: 0.7rem; color: #64748b; margin-top: 0.25rem; }
.ei-sync-coverage { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.45rem; margin: 0.5rem 0; }
.ei-sync-coverage div { background: rgba(2, 6, 23, 0.45); border-radius: 8px; padding: 0.4rem 0.5rem; display: flex; flex-direction: column; gap: 0.15rem; }
.ei-sync-coverage span { font-size: 0.68rem; color: #64748b; }
.ei-sync-coverage strong { font-size: 0.85rem; color: #e2e8f0; }
.ei-sync-events { max-height: 180px; overflow: auto; border-top: 1px solid rgba(51, 65, 85, 0.6); padding-top: 0.45rem; }
.ei-sync-events-title { font-size: 0.72rem; color: #64748b; margin-bottom: 0.35rem; }
.ei-sync-event { display: flex; gap: 0.45rem; font-size: 0.72rem; padding: 0.2rem 0; color: #cbd5e1; }
.ei-sync-event-time { color: #64748b; min-width: 4.2rem; font-variant-numeric: tabular-nums; }
.ei-google-places { margin: 0.55rem 0; padding: 0.55rem 0.65rem; border-radius: 8px; background: rgba(2,6,23,.45); border: 1px solid rgba(100,116,139,.35); }
.ei-google-places-head { display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; margin-bottom: 0.35rem; }
.ei-google-status { font-size: 0.68rem; color: #4ade80; margin-left: auto; }
.ei-google-status.ei-google-missing { color: #fbbf24; }
.ei-google-hint { font-size: 0.7rem; color: #64748b; margin: 0 0 0.45rem; }
.ei-google-key-row { display: flex; gap: 0.4rem; margin-bottom: 0.45rem; }
.ei-google-key-row .form-input { flex: 1; min-width: 0; font-size: 0.75rem; padding: 0.35rem 0.5rem; }
.ei-progress-actions { display:flex; gap:0.5rem; justify-content:center; margin-top:0.85rem; flex-wrap:wrap; }
.ei-btn-cancel {
background: rgba(239,68,68,.18) !important;
border: 1px solid rgba(239,68,68,.55) !important;
color: #fecaca !important;
}
.ei-btn-cancel:hover { background: rgba(239,68,68,.32) !important; }
+591
View File
@@ -0,0 +1,591 @@
/* Foodlinkk UI + Sidebar themes
* UI: <html data-theme="..."> localStorage: flk-theme
* Sidebar:<html data-sidebar="..."> localStorage: flk-sidebar-theme
*/
/* ========== UI THEMES (1–5 existing) ========== */
html[data-theme="ocean"] {
--bg-root: #07131f; --bg-surface: #0c1c2e; --bg-elevated: #123049; --bg-hover: #183a58;
--border-subtle: #1f4b6e; --border-strong: #2d6a96;
--text-primary: #eef9ff; --text-secondary: #b6e3f7; --text-muted: #7eb6d4; --text-body: #d5effc;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #67e8f9; --accent-purple-dim: rgba(103,232,249,.18);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 32px rgba(8,47,73,.55); --flk-theme-glow: rgba(34,211,238,.35);
}
html[data-theme="aurora"] {
--bg-root: #0a1020; --bg-surface: #121a32; --bg-elevated: #1a2550; --bg-hover: #243266;
--border-subtle: #3347a0; --border-strong: #4f6dff;
--text-primary: #f0f4ff; --text-secondary: #c7d2fe; --text-muted: #9db0f5; --text-body: #dde4ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #a78bfa; --accent-purple-dim: rgba(167,139,250,.22);
--accent-amber: #818cf8; --accent-amber-dim: rgba(129,140,248,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 8px 32px rgba(49,46,129,.5); --flk-theme-glow: rgba(167,139,250,.4);
}
html[data-theme="neon"] {
--bg-root: #050b14; --bg-surface: #0a1628; --bg-elevated: #10243f; --bg-hover: #163255;
--border-subtle: #1a4d6e; --border-strong: #06b6d4;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #06b6d4; --accent-cyan-dim: rgba(6,182,212,.28);
--accent-purple: #e879f9; --accent-purple-dim: rgba(232,121,249,.2);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.25);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #ff6bcb; --accent-red-dim: rgba(255,107,203,.22);
--shadow-panel: 0 0 40px rgba(6,182,212,.18); --flk-theme-glow: rgba(6,182,212,.45);
}
html[data-theme="glacier"] {
--bg-root: #0b1522; --bg-surface: #122033; --bg-elevated: #1a2f4a; --bg-hover: #223b5a;
--border-subtle: #2a5078; --border-strong: #3b82f6;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.22);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.2);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 28px rgba(30,64,175,.35); --flk-theme-glow: rgba(56,189,248,.35);
}
html[data-theme="cobalt"] {
--bg-root: #08101c; --bg-surface: #0f1a2e; --bg-elevated: #172642; --bg-hover: #1f3256;
--border-subtle: #274878; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.22);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.18);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 36px rgba(37,99,235,.28); --flk-theme-glow: rgba(59,130,246,.4);
}
/* ========== UI THEMES (6–15 new) ========== */
html[data-theme="reef"] {
--bg-root: #04161a; --bg-surface: #0a242b; --bg-elevated: #11353f; --bg-hover: #174650;
--border-subtle: #1f6b78; --border-strong: #2dd4bf;
--text-primary: #ecfeff; --text-secondary: #99f6e4; --text-muted: #5eead4; --text-body: #ccfbf1;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.2);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.2);
--shadow-panel: 0 10px 36px rgba(13,148,136,.35); --flk-theme-glow: rgba(45,212,191,.45);
}
html[data-theme="plasma"] {
--bg-root: #0c0618; --bg-surface: #180b2e; --bg-elevated: #26114a; --bg-hover: #321860;
--border-subtle: #6d28d9; --border-strong: #a78bfa;
--text-primary: #faf5ff; --text-secondary: #e9d5ff; --text-muted: #c4b5fd; --text-body: #f3e8ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.25);
--accent-amber: #f0abfc; --accent-amber-dim: rgba(240,171,252,.2);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(109,40,217,.4); --flk-theme-glow: rgba(192,132,252,.5);
}
html[data-theme="lagoon"] {
--bg-root: #031a1f; --bg-surface: #072830; --bg-elevated: #0c3a45; --bg-hover: #114c59;
--border-subtle: #0e7490; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.28);
--accent-purple: #2dd4bf; --accent-purple-dim: rgba(45,212,191,.22);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(8,145,178,.35); --flk-theme-glow: rgba(34,211,238,.45);
}
html[data-theme="indigo"] {
--bg-root: #07091a; --bg-surface: #0f1430; --bg-elevated: #1a2250; --bg-hover: #242d68;
--border-subtle: #3730a3; --border-strong: #6366f1;
--text-primary: #eef2ff; --text-secondary: #c7d2fe; --text-muted: #a5b4fc; --text-body: #e0e7ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.25);
--accent-amber: #60a5fa; --accent-amber-dim: rgba(96,165,250,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 10px 36px rgba(67,56,202,.4); --flk-theme-glow: rgba(99,102,241,.45);
}
html[data-theme="skyline"] {
--bg-root: #06101c; --bg-surface: #0c1c33; --bg-elevated: #142d4d; --bg-hover: #1b3c64;
--border-subtle: #0284c7; --border-strong: #38bdf8;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.28);
--accent-purple: #0ea5e9; --accent-purple-dim: rgba(14,165,233,.22);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.22);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(2,132,199,.35); --flk-theme-glow: rgba(56,189,248,.5);
}
html[data-theme="mintvolt"] {
--bg-root: #04140f; --bg-surface: #0a221a; --bg-elevated: #10352a; --bg-hover: #164738;
--border-subtle: #059669; --border-strong: #34d399;
--text-primary: #ecfdf5; --text-secondary: #a7f3d0; --text-muted: #6ee7b7; --text-body: #d1fae5;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #a3e635; --accent-amber-dim: rgba(163,230,53,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.25);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(5,150,105,.35); --flk-theme-glow: rgba(52,211,153,.45);
}
html[data-theme="sapphire"] {
--bg-root: #050d1f; --bg-surface: #0b1835; --bg-elevated: #122650; --bg-hover: #1a3470;
--border-subtle: #1d4ed8; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.25);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.22);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 40px rgba(29,78,216,.4); --flk-theme-glow: rgba(59,130,246,.5);
}
html[data-theme="arctic"] {
--bg-root: #0a121c; --bg-surface: #121c2a; --bg-elevated: #1c2a3d; --bg-hover: #26384f;
--border-subtle: #64748b; --border-strong: #94a3b8;
--text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-muted: #94a3b8; --text-body: #f1f5f9;
--accent-cyan: #67e8f9; --accent-cyan-dim: rgba(103,232,249,.25);
--accent-purple: #7dd3fc; --accent-purple-dim: rgba(125,211,252,.22);
--accent-amber: #bae6fd; --accent-amber-dim: rgba(186,230,253,.2);
--accent-green: #5eead4; --accent-green-dim: rgba(94,234,212,.2);
--accent-red: #fda4af; --accent-red-dim: rgba(253,164,175,.2);
--shadow-panel: 0 10px 32px rgba(100,116,139,.3); --flk-theme-glow: rgba(125,211,252,.4);
}
html[data-theme="hyperion"] {
--bg-root: #041018; --bg-surface: #081c2a; --bg-elevated: #0e2d42; --bg-hover: #143d58;
--border-subtle: #0891b2; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.3);
--accent-purple: #a3e635; --accent-purple-dim: rgba(163,230,53,.22);
--accent-amber: #4ade80; --accent-amber-dim: rgba(74,222,128,.22);
--accent-green: #84cc16; --accent-green-dim: rgba(132,204,22,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 0 42px rgba(8,145,178,.3); --flk-theme-glow: rgba(34,211,238,.55);
}
html[data-theme="meridian"] {
--bg-root: #0a0716; --bg-surface: #140d28; --bg-elevated: #221645; --bg-hover: #2e1e5c;
--border-subtle: #7c3aed; --border-strong: #a78bfa;
--text-primary: #f5f3ff; --text-secondary: #ddd6fe; --text-muted: #c4b5fd; --text-body: #ede9fe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.28);
--accent-amber: #e879f9; --accent-amber-dim: rgba(232,121,249,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(124,58,237,.4); --flk-theme-glow: rgba(167,139,250,.5);
}
html[data-theme] body {
background:
radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent-cyan) 18%, transparent), transparent 60%),
radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--accent-purple) 14%, transparent), transparent 55%),
var(--bg-root);
}
html[data-theme] .panel,
html[data-theme] .hm-neo {
border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle));
}
html[data-theme] .btn-pulse,
html[data-theme] .btn-primary {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 40%, transparent),
0 8px 24px color-mix(in srgb, var(--accent-cyan) 22%, transparent);
}
/* ========== SIDEBAR THEMES (independent, bright) ========== */
html[data-sidebar] .app-sidebar {
border-right: 1px solid color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 45%, transparent) !important;
box-shadow: inset -1px 0 0 color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 20%, transparent),
4px 0 28px color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 12%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill:hover {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 16%, transparent) !important;
border-color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 35%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill.active {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 22%, rgba(8,12,18,.9)) !important;
box-shadow: inset 3px 0 0 var(--flk-sidebar-accent, #22d3ee);
}
html[data-sidebar] .app-sidebar-label {
color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 85%, #fff) !important;
}
html[data-sidebar="aqua"] .app-sidebar {
--flk-sidebar-accent: #5eead4;
--flk-sidebar-solid: #0a4f5c;
--flk-sidebar-grad: linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="azure"] .app-sidebar {
--flk-sidebar-accent: #60a5fa;
--flk-sidebar-solid: #163a7a;
--flk-sidebar-grad: linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="turquoise"] .app-sidebar {
--flk-sidebar-accent: #2dd4bf;
--flk-sidebar-solid: #0a453f;
--flk-sidebar-grad: linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="cerulean"] .app-sidebar {
--flk-sidebar-accent: #38bdf8;
--flk-sidebar-solid: #0c4a6e;
--flk-sidebar-grad: linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="electric"] .app-sidebar {
--flk-sidebar-accent: #22d3ee;
--flk-sidebar-solid: #0a3d4a;
--flk-sidebar-grad: linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="violet"] .app-sidebar {
--flk-sidebar-accent: #818cf8;
--flk-sidebar-solid: #2a1f6b;
--flk-sidebar-grad: linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="emerald"] .app-sidebar {
--flk-sidebar-accent: #34d399;
--flk-sidebar-solid: #0a4a38;
--flk-sidebar-grad: linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="midnight"] .app-sidebar {
--flk-sidebar-accent: #7dd3fc;
--flk-sidebar-solid: #0d1f3c;
--flk-sidebar-grad: linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="steel"] .app-sidebar {
--flk-sidebar-accent: #94a3b8;
--flk-sidebar-solid: #1a2d42;
--flk-sidebar-grad: linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="nordic"] .app-sidebar {
--flk-sidebar-accent: #67e8f9;
--flk-sidebar-solid: #0e3a4f;
--flk-sidebar-grad: linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%);
background: var(--flk-sidebar-grad) !important;
}
/* Sidebar fill mode: gradient (default) vs plain solid */
html[data-sidebar][data-sidebar-style="plain"] .app-sidebar {
background: var(--flk-sidebar-solid, #0a3d4a) !important;
}
html[data-sidebar]:not([data-sidebar-style="plain"]) .app-sidebar {
background: var(--flk-sidebar-grad) !important;
}
.flk-style-toggle {
display: inline-flex;
gap: 0.35rem;
margin: 0.65rem 0 0.85rem;
padding: 0.25rem;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent);
}
.flk-style-toggle button {
border: 0;
background: transparent;
color: var(--text-secondary, #b6e3f7);
padding: 0.4rem 0.85rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.flk-style-toggle button.is-active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent);
color: #fff;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan, #22d3ee) 45%, transparent);
}
/* Settings cards */
.flk-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
margin-top: 0.85rem;
}
.flk-theme-card {
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 0.75rem;
background: var(--bg-elevated);
cursor: pointer;
text-align: left;
color: inherit;
transition: border-color .15s, transform .15s, box-shadow .15s;
}
.flk-theme-card:hover { transform: translateY(-2px); border-color: var(--accent-cyan); }
.flk-theme-card.is-active {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim), 0 12px 28px rgba(0,0,0,.35);
}
.flk-theme-swatch {
height: 52px; border-radius: 10px; margin-bottom: 0.55rem;
border: 1px solid rgba(255,255,255,.18);
background-color: #1e293b;
background-size: cover !important;
background-repeat: no-repeat !important;
}
.flk-theme-card strong { display: block; margin-bottom: 0.12rem; font-size: 0.9rem; }
.flk-theme-card small { opacity: 0.75; line-height: 1.3; display: block; font-size: 0.75rem; }
.flk-theme-section { margin-top: 1.75rem; }
.flk-theme-section h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
/* ========== PAGE BACKGROUNDS (independent of UI theme) ==========
* <html data-bg="..."> localStorage: flk-page-bg
* Applies to body + app-main + common subpage shells
*/
html[data-bg="navy"] { --flk-page-bg: #0d2b5c; --flk-page-bg2: #134078; --bg-root: #0d2b5c; }
html[data-bg="royal"] { --flk-page-bg: #1a3a8f; --flk-page-bg2: #274bb8; --bg-root: #1a3a8f; }
html[data-bg="azure"] { --flk-page-bg: #0a4f8a; --flk-page-bg2: #0d6cb8; --bg-root: #0a4f8a; }
html[data-bg="sky"] { --flk-page-bg: #0e5f9e; --flk-page-bg2: #1880c9; --bg-root: #0e5f9e; }
html[data-bg="cerulean"] { --flk-page-bg: #0c6a9e; --flk-page-bg2: #1490c8; --bg-root: #0c6a9e; }
html[data-bg="teal"] { --flk-page-bg: #0c5c68; --flk-page-bg2: #128090; --bg-root: #0c5c68; }
html[data-bg="lagoon"] { --flk-page-bg: #0a6b7a; --flk-page-bg2: #0f8fa3; --bg-root: #0a6b7a; }
html[data-bg="cyan"] { --flk-page-bg: #0a5568; --flk-page-bg2: #0e7a94; --bg-root: #0a5568; }
html[data-bg="indigo"] { --flk-page-bg: #2f2a7a; --flk-page-bg2: #4338ca; --bg-root: #2f2a7a; }
html[data-bg="violet"] { --flk-page-bg: #4a1d7a; --flk-page-bg2: #6d28d9; --bg-root: #4a1d7a; }
html[data-bg="sapphire"] { --flk-page-bg: #123a8c; --flk-page-bg2: #1d4ed8; --bg-root: #123a8c; }
html[data-bg="midnight"] { --flk-page-bg: #1a2f55; --flk-page-bg2: #274878; --bg-root: #1a2f55; }
html[data-bg="emerald"] { --flk-page-bg: #0d5c45; --flk-page-bg2: #059669; --bg-root: #0d5c45; }
html[data-bg="slate"] { --flk-page-bg: #243447; --flk-page-bg2: #334155; --bg-root: #243447; }
html[data-bg="graphite"] { --flk-page-bg: #1e2a3a; --flk-page-bg2: #2c3b52; --bg-root: #1e2a3a; }
html[data-bg] body {
background:
radial-gradient(ellipse 90% 55% at 12% -8%, color-mix(in srgb, var(--accent-cyan, #22d3ee) 18%, transparent), transparent 55%),
radial-gradient(ellipse 70% 45% at 92% 8%, color-mix(in srgb, var(--accent-purple, #818cf8) 14%, transparent), transparent 50%),
linear-gradient(165deg, var(--flk-page-bg) 0%, var(--flk-page-bg2, var(--flk-page-bg)) 100%) !important;
background-attachment: fixed !important;
}
html[data-bg] .app-main,
html[data-bg] .app-shell,
html[data-bg] main,
html[data-bg] .page,
html[data-bg] .page-wrap,
html[data-bg] .content-area,
html[data-bg] .settings-layout,
html[data-bg] .hm-page,
html[data-bg] .warroom-root,
html[data-bg] .agents-page {
background: transparent !important;
}
html[data-bg] .panel,
html[data-bg] .hm-neo,
html[data-bg] .card,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Kill leftover near-black shells when page bg is set */
html[data-bg] .app-main > *,
html[data-bg] .dashboard-root,
html[data-bg] .retail-page,
html[data-bg] .ops-page,
html[data-bg] .documents-page,
html[data-bg] .analytics-wrap,
html[data-bg] .export-intel,
html[data-bg] .clients-page,
html[data-bg] .wr-shell,
html[data-bg] .voice-page,
html[data-bg] .studio-page,
html[data-bg] .hermes-page,
html[data-bg] .projects-hub,
html[data-bg] .packaging-page,
html[data-bg] .beurs-page,
html[data-bg] .marketing-page,
html[data-bg] .revenue-cockpit {
background-color: transparent !important;
}
html[data-bg] {
background: var(--flk-page-bg) !important;
}
/* ========== SURFACE OVERRIDE (kill leftover blacks on subpages) ========== */
html[data-theme],
html[data-bg] {
--flk-surface: color-mix(in srgb, var(--bg-elevated, #123049) 88%, #fff 0%);
--flk-surface-2: color-mix(in srgb, var(--bg-surface, #0c1c2e) 92%, var(--accent-cyan, #22d3ee) 8%);
--flk-input: color-mix(in srgb, var(--bg-root, #07131f) 70%, var(--bg-elevated, #123049) 30%);
--flk-panel: color-mix(in srgb, var(--bg-elevated, #123049) 75%, transparent);
}
/* When a page background is chosen, lift surface tokens off near-black */
html[data-bg="navy"] { --bg-surface: #134078; --bg-elevated: #1a4f96; --bg-hover: #2460b0; }
html[data-bg="royal"] { --bg-surface: #274bb8; --bg-elevated: #3359d4; --bg-hover: #4169e8; }
html[data-bg="azure"] { --bg-surface: #0d6cb8; --bg-elevated: #1580d4; --bg-hover: #1e92ea; }
html[data-bg="sky"] { --bg-surface: #1880c9; --bg-elevated: #2493de; --bg-hover: #36a4ec; }
html[data-bg="cerulean"] { --bg-surface: #1490c8; --bg-elevated: #20a3dc; --bg-hover: #32b4ea; }
html[data-bg="teal"] { --bg-surface: #128090; --bg-elevated: #1898a8; --bg-hover: #22aebb; }
html[data-bg="lagoon"] { --bg-surface: #0f8fa3; --bg-elevated: #18a4ba; --bg-hover: #24b8ce; }
html[data-bg="cyan"] { --bg-surface: #0e7a94; --bg-elevated: #1490ae; --bg-hover: #1ea4c4; }
html[data-bg="indigo"] { --bg-surface: #4338ca; --bg-elevated: #5450e0; --bg-hover: #6666f0; }
html[data-bg="violet"] { --bg-surface: #6d28d9; --bg-elevated: #7e3aec; --bg-hover: #8f4cf8; }
html[data-bg="sapphire"] { --bg-surface: #1d4ed8; --bg-elevated: #2b5fec; --bg-hover: #3b70f8; }
html[data-bg="midnight"] { --bg-surface: #274878; --bg-elevated: #335c96; --bg-hover: #4070b0; }
html[data-bg="emerald"] { --bg-surface: #059669; --bg-elevated: #0aad78; --bg-hover: #14c08a; }
html[data-bg="slate"] { --bg-surface: #334155; --bg-elevated: #3f5068; --bg-hover: #4d6078; }
html[data-bg="graphite"] { --bg-surface: #2c3b52; --bg-elevated: #384a66; --bg-hover: #465a78; }
/* App chrome */
html[data-theme] .app-topbar,
html[data-bg] .app-topbar,
html[data-theme] .app-main,
html[data-bg] .app-main {
background: transparent !important;
}
html[data-theme] .app-topbar,
html[data-bg] .app-topbar {
background: color-mix(in srgb, var(--bg-elevated, #123049) 55%, transparent) !important;
border-bottom-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
backdrop-filter: blur(10px);
}
/* Generic panels / cards */
html[data-theme] .panel,
html[data-bg] .panel,
html[data-theme] .card,
html[data-bg] .card,
html[data-theme] .hm-neo,
html[data-bg] .hm-neo,
html[data-theme] .settings-panel,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 78%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--border-subtle, #1f4b6e)) !important;
}
/* ========== Export Intel surfaces ========== */
html[data-theme] .ei-page,
html[data-bg] .ei-page {
background: transparent !important;
}
html[data-theme] .ei-kpi,
html[data-bg] .ei-kpi,
html[data-theme] .ei-neo-kpi,
html[data-bg] .ei-neo-kpi {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-neo-ring-inner,
html[data-bg] .ei-neo-ring-inner {
background: var(--bg-surface, #0c1c2e) !important;
}
html[data-theme] .ei-filter-bar,
html[data-bg] .ei-filter-bar {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent-cyan, #22d3ee) 16%, var(--bg-elevated, #123049)),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 90%, transparent)
) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-filters select,
html[data-theme] .ei-filters input,
html[data-theme] .ei-filter-label select,
html[data-theme] .ei-filter-label input,
html[data-bg] .ei-filters select,
html[data-bg] .ei-filters input,
html[data-bg] .ei-filter-label select,
html[data-bg] .ei-filter-label input {
background: color-mix(in srgb, var(--bg-root, #07131f) 55%, var(--bg-elevated, #123049) 45%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
color: var(--text-primary, #eef9ff) !important;
}
html[data-theme] .ei-tab-panel,
html[data-bg] .ei-tab-panel,
html[data-theme] .ei-tab-panel:has(.ei-workspace-body),
html[data-bg] .ei-tab-panel:has(.ei-workspace-body) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 72%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent) !important;
}
html[data-theme] .ei-side-panel,
html[data-bg] .ei-side-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-elevated, #123049) 92%, #fff 0%),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 95%, transparent)
) !important;
border-left-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
}
html[data-theme] .ei-side-panel-head,
html[data-bg] .ei-side-panel-head {
background: color-mix(in srgb, var(--bg-hover, #183a58) 55%, transparent) !important;
}
html[data-theme] .ei-map-wrap,
html[data-bg] .ei-map-wrap {
background: color-mix(in srgb, var(--bg-root, #07131f) 80%, var(--bg-elevated, #123049) 20%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-map-loading,
html[data-bg] .ei-map-loading {
background: color-mix(in srgb, var(--bg-root, #07131f) 70%, transparent) !important;
}
html[data-theme] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-theme] .ei-map-wrap .leaflet-popup-tip,
html[data-bg] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-bg] .ei-map-wrap .leaflet-popup-tip {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 40%, transparent) !important;
}
html[data-theme] .ei-table th,
html[data-bg] .ei-table th {
background: var(--bg-elevated, #123049) !important;
}
html[data-theme] .ei-table tbody tr,
html[data-bg] .ei-table tbody tr {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent);
}
html[data-theme] .ei-table tbody tr:nth-child(even),
html[data-bg] .ei-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 45%, transparent);
}
html[data-theme] .ei-drawer,
html[data-bg] .ei-drawer,
html[data-theme] .ei-modal-card,
html[data-bg] .ei-modal-card,
html[data-theme] .ei-progress-card,
html[data-bg] .ei-progress-card {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-monitor,
html[data-bg] .ei-sync-monitor {
background: color-mix(in srgb, var(--bg-elevated, #123049) 88%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-job,
html[data-bg] .ei-sync-job,
html[data-theme] .ei-sync-coverage div,
html[data-bg] .ei-sync-coverage div,
html[data-theme] .ei-google-places,
html[data-bg] .ei-google-places {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 70%, transparent) !important;
}
html[data-theme] .wereldexport-workspace > .vtabs-nav,
html[data-bg] .wereldexport-workspace > .vtabs-nav {
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent) !important;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent);
}
html[data-theme] .vtab-btn,
html[data-bg] .vtab-btn {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent) !important;
}
html[data-theme] .vtab-btn.active,
html[data-bg] .vtab-btn.active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--bg-elevated, #123049)) !important;
}
/* Persona / brand switch in topbar */
html[data-theme] .persona-btn,
html[data-bg] .persona-btn,
html[data-theme] .brand-switch .persona-btn,
html[data-bg] .brand-switch .persona-btn {
background: color-mix(in srgb, var(--bg-elevated, #123049) 60%, transparent) !important;
}
+188 -32
View File
@@ -1,34 +1,72 @@
/* 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;
/* Herman / Hermes chat — always-on floating dock */
.ha-dock {
/* wrapper only; FAB/panel are fixed to viewport */
}
.ha-dock-hidden {
display: none !important;
}
.ha-fab {
position: fixed !important;
right: max(1.25rem, env(safe-area-inset-right, 0px));
bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
z-index: 10050 !important;
height: 52px;
width: auto;
min-width: 52px;
padding: 0 1.15rem 0 0.9rem;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.65);
background: linear-gradient(145deg, #0ea5e9 0%, #0284c7 55%, #0369a1 100%);
color: #f8fafc;
font-size: 1.15rem;
cursor: pointer;
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0.45rem;
box-shadow:
0 0 0 4px rgba(14, 165, 233, 0.18),
0 10px 28px rgba(0, 0, 0, 0.5);
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
pointer-events: auto;
}
.ha-fab-icon { line-height: 1; font-size: 1.2rem; }
.ha-fab-label {
font-size: 0.88rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: none;
}
.ha-fab:hover {
transform: translateY(-2px) scale(1.03);
box-shadow:
0 0 0 5px rgba(14, 165, 233, 0.28),
0 14px 34px rgba(0, 0, 0, 0.55);
}
.ha-fab.is-open {
background: linear-gradient(145deg, #4c1d95, #1e1b4b);
border-color: rgba(167, 139, 250, 0.7);
box-shadow:
0 0 0 4px rgba(167, 139, 250, 0.2),
0 10px 28px rgba(0, 0, 0, 0.5);
}
.ha-fab.is-busy {
border-color: #38bdf8;
animation: ha-pulse 1.2s infinite;
}
.ha-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));
position: fixed !important;
right: max(1rem, env(safe-area-inset-right, 0px));
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
z-index: 10049 !important;
width: min(420px, calc(100vw - 2rem));
max-height: min(620px, calc(100vh - 8rem));
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(167, 139, 250, 0.35);
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(10, 14, 20, 0.97);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
overflow: hidden;
@@ -41,11 +79,33 @@
padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(167, 139, 250, 0.08);
gap: 0.5rem;
}
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
.ha-head-title { min-width: 0; }
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; display: flex; align-items: center; gap: 0.4rem; }
.ha-badge {
font-size: 0.62rem;
font-weight: 600;
color: #ddd6fe;
background: rgba(124, 58, 237, 0.35);
border: 1px solid rgba(167, 139, 250, 0.45);
border-radius: 999px;
padding: 0.12rem 0.45rem;
letter-spacing: 0.02em;
}
.ha-conn {
margin: 0.15rem 0 0;
font-size: 0.62rem;
color: #64748b;
}
.ha-conn.is-ok { color: #34d399; }
.ha-conn.is-err { color: #f87171; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; flex-shrink: 0; }
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
.ha-text-btn { font-size: 0.68rem !important; color: #94a3b8 !important; padding: 0.2rem 0.35rem !important; }
.ha-text-btn:hover { color: #e2e8f0 !important; }
.ha-text-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ha-confirm {
padding: 0.5rem 0.75rem;
@@ -63,8 +123,8 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 200px;
max-height: 320px;
min-height: 220px;
max-height: 360px;
}
.ha-bubble {
max-width: 92%;
@@ -103,6 +163,7 @@
padding: 0.55rem 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
align-items: center;
margin: 0;
}
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
.ha-mic {
@@ -122,7 +183,20 @@
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-status { font-size: 0.72rem; color: #cbd5e1; padding: 0.25rem 0.65rem 0.4rem; line-height: 1.35; }
.ha-status.is-error { color: #fecaca; background: rgba(127, 29, 29, 0.35); border-radius: 6px; margin: 0 0.5rem 0.35rem; padding: 0.4rem 0.55rem; }
.ha-secure-hint {
margin: 0.25rem 0.55rem 0.45rem;
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.45);
color: #e2e8f0;
font-size: 0.78rem;
line-height: 1.4;
}
.ha-secure-hint strong { color: #f8fafc; display: block; margin-bottom: 0.25rem; }
.ha-secure-hint a { display: inline-block; margin-top: 0.4rem; }
.ha-results-modal { z-index: 1600; }
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
@@ -143,6 +217,88 @@
.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; }
.ha-fab {
right: 1rem;
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); /* above mobile bottom nav */
}
.ha-panel {
right: 0.5rem;
left: 0.5rem;
width: auto;
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
}
}
.ha-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.9rem;
padding: 0.15rem 0.65rem 0.35rem;
font-size: 0.68rem;
color: #94a3b8;
}
.ha-toggles label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
user-select: none;
}
.ha-toggles input {
accent-color: #38bdf8;
margin: 0;
}
.ha-mic.is-listening {
box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.35);
}
/* Toasts boven de Herman FAB, niet erachter */
.toast-container {
bottom: 5.75rem !important;
right: 1rem !important;
left: auto !important;
z-index: 10120 !important;
max-width: min(360px, calc(100vw - 2rem));
}
.toast {
color: #f1f5f9 !important;
background: #0f172a !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.toast-error { border-color: #f87171 !important; color: #fecaca !important; }
.ha-https-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0.55rem 0.4rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.4);
color: #e2e8f0;
font-size: 0.75rem;
}
.ha-https-prompt .btn { flex-shrink: 0; }
.ha-https-prompt .ha-text-btn { margin-left: auto; color: #94a3b8; }
.ha-fab.has-custom-pos {
right: auto !important;
bottom: auto !important;
}
.ha-fab.is-dragging {
cursor: grabbing !important;
transition: none !important;
transform: none !important;
user-select: none;
touch-action: none;
}
.ha-fab {
touch-action: none;
user-select: none;
}
.ha-panel.has-custom-pos {
right: auto !important;
bottom: auto !important;
}
+495
View File
@@ -894,3 +894,498 @@
.herman-rss-side-item small { color: #64748b; font-size: 0.65rem; }
.herman-rss-col-side .hm-chart-panel { margin-bottom: 0.75rem; }
.herman-rss-col-side h4 { margin: 0 0 0.5rem; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
/* CEO clean layout — briefing first, less noise */
.hm-ceo-clean .hm-briefing-hero {
order: -1;
}
.hm-briefing-clean {
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
gap: 1.25rem;
}
.hm-briefing-clean .briefing-card.summary {
padding: 1.35rem 1.5rem;
}
.hm-briefing-clean .live-digest-body {
font-size: 0.95rem;
line-height: 1.55;
color: #e2e8f0;
}
.hm-briefing-clean .live-digest-section {
margin-bottom: 1.1rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.hm-briefing-clean .live-digest-section:last-child {
border-bottom: 0;
}
.hm-ceo-clean .hm-section-title {
letter-spacing: 0.02em;
}
.hm-ceo-clean .beurs-mini-chip {
border-radius: 10px;
padding: 0.4rem 0.75rem;
}
.hm-ceo-clean .hm-eq { display: none; }
@media (max-width: 960px) {
.hm-briefing-clean { grid-template-columns: 1fr; }
}
/* Retail & markt removed from CEO overview — RSS lives in RSS tab */
.hm-ceo-clean [data-widget="retail"] { display: none !important; }
/* CEO overview: only dagmemo, no horizon/activity */
.hm-ceo-clean .briefing-horizon-col,
.hm-ceo-clean .horizon-card,
.hm-ceo-clean .hm-activity-timeline { display: none !important; }
.hm-ceo-clean .hm-briefing-solo {
grid-template-columns: 1fr;
}
.hm-ceo-clean .hm-briefing-solo .briefing-card.summary {
max-width: none;
}
/* Force CEO overview order (prefs/JS cannot override) */
.hm-ceo-clean {
display: flex;
flex-direction: column;
}
.hm-ceo-clean > [data-widget="kpis"] { order: 1; }
.hm-ceo-clean > [data-widget="executive"] { order: 2; }
.hm-ceo-clean > [data-widget="analytics"] { order: 3; }
.hm-ceo-clean > [data-widget="briefing"] { order: 4; }
.hm-ceo-clean > [data-widget="news"] { order: 5; }
.hm-ceo-clean > [data-widget="feed"] { order: 6; }
.hm-ceo-clean > [data-widget="approvals"] { order: 7; }
.hm-ceo-clean > [data-widget="retail"] { order: 99; display: none !important; }
/* ═══ CEO PRO LAYOUT ═══ */
.hm-ceo-pro {
gap: 1.15rem;
}
.hm-ceo-pro .hm-dash-section {
margin: 0;
}
.hm-ceo-pro .hm-section-title { display: block; }
/* KPI row — bold hierarchy */
.hm-ceo-kpi-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 1100px) {
.hm-ceo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.hm-ceo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.hm-ceo-kpi-link { text-decoration: none; color: inherit; display: block; }
.hm-ceo-kpi {
background: linear-gradient(165deg, rgba(18, 28, 42, 0.95), rgba(8, 12, 20, 0.98));
border: 1px solid rgba(255,255,255,0.07);
border-top: 2px solid var(--kpi-accent, #00e5ff);
border-radius: 14px;
padding: 1rem 1.05rem 0.9rem;
min-height: 92px;
transition: border-color 0.15s, transform 0.15s;
}
.hm-ceo-kpi-link:hover .hm-ceo-kpi {
border-color: rgba(0, 229, 255, 0.35);
transform: translateY(-1px);
}
.hm-ceo-kpi-label {
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8b9cb3;
margin-bottom: 0.35rem;
}
.hm-ceo-kpi-value {
font-size: 1.55rem;
font-weight: 700;
letter-spacing: -0.02em;
color: #f1f5f9;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.hm-ceo-kpi-sub {
margin-top: 0.3rem;
font-size: 0.7rem;
color: #64748b;
}
/* B2B | B2C */
.hm-ceo-brand-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 860px) {
.hm-ceo-brand-grid { grid-template-columns: 1fr; }
}
.hm-ceo-brand-card {
background: rgba(10, 16, 26, 0.92);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 16px;
padding: 1.1rem 1.2rem 1rem;
}
.hm-ceo-brand-card.brand-foodlinkk { border-left: 3px solid #38bdf8; }
.hm-ceo-brand-card.brand-cucina { border-left: 3px solid #fb923c; }
.hm-ceo-brand-card header {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.75rem;
}
.hm-ceo-brand-card h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
color: #e2e8f0;
letter-spacing: 0.01em;
}
.hm-ceo-brand-icon { font-size: 1.15rem; }
.hm-ceo-brand-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.hm-ceo-brand-list li {
font-size: 0.84rem;
line-height: 1.45;
color: #cbd5e1;
padding-left: 0.7rem;
border-left: 2px solid rgba(148,163,184,0.2);
}
/* Focus + Trends */
.hm-ceo-mid-grid {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 0.9rem;
}
@media (max-width: 860px) {
.hm-ceo-mid-grid { grid-template-columns: 1fr; }
}
.hm-ceo-panel {
background: rgba(10, 16, 26, 0.92);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 16px;
padding: 1rem 1.15rem;
}
.hm-ceo-panel header h3 {
margin: 0 0 0.7rem;
font-size: 0.88rem;
font-weight: 650;
color: #e2e8f0;
}
.hm-ceo-focus { border-top: 2px solid #f472b6; }
.hm-ceo-trends { border-top: 2px solid #34d399; }
.hm-ceo-action-list,
.hm-ceo-trend-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.hm-ceo-action-list li,
.hm-ceo-trend-list li {
font-size: 0.88rem;
color: #e2e8f0;
line-height: 1.4;
}
.hm-ceo-bullet {
color: #f472b6;
font-weight: 700;
margin-right: 0.25rem;
}
.hm-ceo-trend-list li {
color: #a7f3d0;
font-weight: 500;
}
/* Herman memo — collapsed by default */
.hm-ceo-memo {
margin-top: 0.9rem;
background: rgba(10, 16, 26, 0.7);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 0.55rem 0.9rem;
}
.hm-ceo-memo summary {
cursor: pointer;
color: #94a3b8;
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
list-style: none;
}
.hm-ceo-memo summary::-webkit-details-marker { display: none; }
.hm-ceo-memo[open] summary { margin-bottom: 0.65rem; color: #cbd5e1; }
.hm-ceo-memo-body {
font-size: 0.86rem;
color: #cbd5e1;
line-height: 1.55;
}
.hm-ceo-memo-body .live-digest-body {
display: block !important;
grid-template-columns: none !important;
}
/* News collapsible */
.hm-ceo-news-details {
background: rgba(10, 16, 26, 0.85);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
padding: 0.75rem 1rem 0.9rem;
}
.hm-ceo-news-details summary {
cursor: pointer;
font-size: 0.9rem;
font-weight: 650;
color: #e2e8f0;
list-style: none;
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.hm-ceo-news-details summary::-webkit-details-marker { display: none; }
.hm-ceo-news-details summary small {
font-weight: 400;
font-size: 0.72rem;
color: #64748b;
}
.hm-ceo-news-details summary a { color: #38bdf8; }
.hm-ceo-news-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hm-ceo-news-list li {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: rgba(0,0,0,0.22);
}
.hm-ceo-news-list a {
color: #e2e8f0;
text-decoration: none;
font-size: 0.86rem;
line-height: 1.35;
}
.hm-ceo-news-list a:hover { color: #7dd3fc; }
.hm-ceo-news-list small { color: #64748b; font-size: 0.7rem; }
/* Hide noisy chrome on CEO pro */
.hm-ceo-pro ~ .herman-browser,
.herman-shell:has(.hm-ceo-pro) .hm-eq { display: none; }
.hm-ceo-memo-open {
margin-top: 0.9rem;
background: rgba(10, 16, 26, 0.85);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
padding: 0.9rem 1.1rem;
}
.hm-ceo-memo-label {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 0.55rem;
}
/* Pipeline HTML bars — geen Chart.js ghosting */
.hm-pipeline-host { min-height: 140px; }
.hm-pipeline-bars { padding: 0.25rem 0.15rem 0.5rem; }
.hm-pipeline-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 0.85rem; gap: 0.75rem;
}
.hm-pipeline-head strong { color: #f1f5f9; font-size: 1.05rem; }
.hm-pipeline-head span { color: #94a3b8; font-size: 0.82rem; }
.hm-pipeline-list { display: flex; flex-direction: column; gap: 0.7rem; }
.hm-pipeline-meta {
display: flex; justify-content: space-between; gap: 0.75rem;
margin-bottom: 0.3rem; font-size: 0.82rem;
}
.hm-pipeline-stage { color: #e2e8f0; font-weight: 600; text-transform: capitalize; }
.hm-pipeline-vals { color: #94a3b8; }
.hm-pipeline-track {
height: 10px; border-radius: 999px;
background: rgba(255,255,255,0.06); overflow: hidden;
}
.hm-pipeline-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.hm-pipeline-foot { margin: 0.85rem 0 0; font-size: 0.78rem; }
.hm-pipeline-foot a { color: #38bdf8; text-decoration: none; }
/* CEO context-rich overview */
.hm-overview-panel { border-color: rgba(0,229,255,0.14); }
.hm-context-banner {
background: linear-gradient(120deg, rgba(14, 116, 144, 0.18), rgba(15, 23, 42, 0.4));
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 0.9rem;
}
.hm-context-banner p {
margin: 0;
color: #e2e8f0;
font-size: 0.92rem;
line-height: 1.5;
}
.hm-exec-grid-rich {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 900px) {
.hm-exec-grid-rich { grid-template-columns: 1fr 1fr; }
}
.hm-exec-rich {
background: rgba(8, 12, 20, 0.65);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 0.75rem 0.85rem;
text-decoration: none;
color: inherit;
display: flex;
gap: 0.65rem;
align-items: flex-start;
transition: border-color 0.15s, transform 0.15s;
}
.hm-exec-rich:hover { border-color: rgba(0,229,255,0.35); transform: translateY(-1px); }
.hm-exec-rich .hm-exec-val { font-size: 1.2rem; font-weight: 700; color: #f8fafc; margin-top: 0.15rem; }
.hm-exec-sub { font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
.hm-context-block { margin-top: 0.95rem; }
.hm-context-block h4 {
margin: 0 0 0.45rem;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #94a3b8;
}
.hm-deal-chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.hm-deal-chip {
display: inline-flex; align-items: center; gap: 0.45rem;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 999px;
padding: 0.35rem 0.7rem;
text-decoration: none;
color: #e2e8f0;
font-size: 0.78rem;
}
.hm-deal-chip span { color: #86efac; font-weight: 650; }
.hm-deal-chip em { color: #94a3b8; font-style: normal; font-size: 0.7rem; }
.hm-deal-chip-client span { color: #7dd3fc; }
.hm-context-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.hm-context-list li { font-size: 0.84rem; color: #cbd5e1; }
.hm-score {
display: inline-block; margin-left: 0.25rem;
font-size: 0.68rem; color: #fbbf24;
background: rgba(251,191,36,0.12);
border-radius: 4px; padding: 0.05rem 0.35rem;
}
.hm-ceo-brand-card { min-height: 160px; }
.hm-ceo-pro .hm-briefing-hero, .hm-ceo-pro .hm-ceo-mid { margin-top: 0.15rem; }
/* v32-kpi-six — 6 kerncijfers */
.hm-neo-kpi-row {
display: grid !important;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 1400px) {
.hm-neo-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 800px) {
.hm-neo-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* v33: CEO pipeline compact — geen lege scroll-ruimte */
.hm-ceo-clean .hm-dash-analytics .hm-analytics-panel,
.hm-ceo-pro .hm-dash-analytics .hm-analytics-panel {
min-height: 0 !important;
height: auto !important;
padding: 0.75rem 0.9rem;
}
.hm-ceo-clean .hm-pipeline-host,
.hm-ceo-pro .hm-pipeline-host,
.hm-ceo-clean .hm-dash-analytics .hm-chart-wrap,
.hm-ceo-pro .hm-dash-analytics .hm-chart-wrap {
height: auto !important;
min-height: 0 !important;
}
.hm-ceo-clean .hm-neo-charts,
.hm-ceo-pro .hm-neo-charts {
display: block;
margin-bottom: 0;
}
.hm-ceo-clean .hm-dash-analytics .hm-section-title {
display: none; /* panel head is genoeg — geen dubbele Pipeline-titel */
}
.hm-ceo-clean .hm-pipeline-bars {
padding: 0;
}
.hm-ceo-clean .hm-pipeline-list {
gap: 0.45rem;
}
.hm-ceo-clean .hm-pipeline-foot {
margin: 0.55rem 0 0;
}
/* Briefing + nieuws compacter */
.hm-ceo-clean .hm-ceo-mid-grid {
gap: 0.65rem;
}
.hm-ceo-clean .hm-ceo-panel {
padding: 0.7rem 0.85rem;
}
.hm-ceo-clean .hm-ceo-memo {
margin-top: 0.65rem;
max-height: none;
}
.hm-ceo-clean .hm-ceo-memo-body {
max-height: 180px;
overflow-y: auto;
font-size: 0.88rem;
line-height: 1.45;
}
.hm-ceo-clean .hm-ceo-memo:not([open]) .hm-ceo-memo-body {
display: none;
}
.hm-ceo-clean .hm-ceo-news-details {
margin: 0;
}
.hm-ceo-clean .hm-ceo-action-list,
.hm-ceo-clean .hm-ceo-trend-list {
max-height: 140px;
overflow-y: auto;
margin: 0;
}
/* Minder verticale gap tussen secties */
.hm-ceo-pro {
gap: 0.85rem !important;
}
.hm-ceo-clean .hm-dash-section {
margin-bottom: 0 !important;
}
+159
View File
@@ -216,3 +216,162 @@
.dot-in { background: var(--hm-cyan); }
.dot-out { background: var(--hm-purple); }
.dot-edge { background: var(--hm-orange); }
.hm-section-title {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
margin: 1.25rem 0 0.75rem;
}
.hm-bucket-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.hm-bucket-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 12px;
padding: 0.85rem 0.9rem;
}
.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; }
.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; }
.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
.hm-tool-list li {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
font-size: 0.78rem; color: #cbd5e1;
}
.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; }
.hm-tool-list-tall { max-height: 280px; overflow: auto; }
.status-badge.on { color: #b8ff3c; }
.status-badge.off { color: #f87171; }
/* Clickable buckets + explorer modal */
.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; }
.hm-bucket-click {
display: block; width: 100%; text-align: left; cursor: pointer;
transition: border-color .15s, transform .12s, background .15s;
font: inherit; color: inherit;
}
.hm-bucket-click:hover {
border-color: rgba(0, 229, 255, 0.55);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.hm-bucket-open {
margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75;
}
.hm-bucket-click:hover .hm-bucket-open { opacity: 1; }
.hm-modal-backdrop {
position: fixed; inset: 0; z-index: 80;
background: rgba(2, 6, 14, 0.72);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.hm-modal {
width: min(1100px, 96vw);
height: min(820px, 92vh);
background: #0b1220;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 14px;
box-shadow: 0 24px 80px rgba(0,0,0,0.55);
display: flex; flex-direction: column;
overflow: hidden;
}
.hm-modal-head {
display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start;
padding: 1rem 1.1rem 0.75rem;
border-bottom: 1px solid rgba(148,163,184,0.12);
}
.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.hm-modal-close {
background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem;
}
.hm-modal-close:hover { color: #fff; }
.hm-chip {
border: 1px solid rgba(148,163,184,0.25);
background: rgba(15,23,42,0.7);
color: #cbd5e1; border-radius: 999px;
padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer;
}
.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); }
.hm-modal-tabs {
display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0;
border-bottom: 1px solid rgba(148,163,184,0.1);
}
.hm-modal-tabs button {
background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem;
font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent;
}
.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; }
.hm-modal-body { flex: 1; min-height: 0; }
.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; }
.hm-modal-side {
border-right: 1px solid rgba(148,163,184,0.12);
overflow: auto; background: rgba(8, 12, 20, 0.9);
position: relative;
}
.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; }
.hm-side-loading {
position: sticky; top: 0; z-index: 2;
padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8;
background: rgba(14,165,233,0.08);
}
.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; }
.hm-event-item, .hm-file-item {
width: 100%; text-align: left; cursor: pointer;
background: transparent; border: 1px solid transparent; border-radius: 8px;
padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem;
}
.hm-event-item:hover, .hm-file-item:hover,
.hm-event-item.active, .hm-file-item.active {
background: rgba(14,165,233,0.1);
border-color: rgba(56,189,248,0.25);
}
.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; }
.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; }
.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; }
.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; }
.hm-event-preview, .hm-file-path {
font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hm-file-name { display: flex; gap: 0.4rem; align-items: center; }
.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; }
.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; }
.hm-explorer-bar {
display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;
padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1);
position: sticky; top: 0; background: #0b1220; z-index: 1;
}
.hm-explorer-path {
font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; }
.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; }
.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; }
.hm-preview-code {
margin: 0; padding: 0.85rem; border-radius: 10px;
background: #020617; border: 1px solid rgba(148,163,184,0.15);
color: #e2e8f0; font-size: 0.72rem; line-height: 1.45;
white-space: pre-wrap; word-break: break-word; max-height: none;
}
.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; }
.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; }
@media (max-width: 820px) {
.hm-modal { height: 94vh; }
.hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; }
.hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); }
}
+99
View File
@@ -0,0 +1,99 @@
:root {
--bg0: #06090e;
--bg1: #0c121b;
--card: #111821;
--line: rgba(148, 163, 184, 0.18);
--text: #f1f5f9;
--muted: #94a3b8;
--gold: #fbbf24;
--green: #22c55e;
--danger: #f87171;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
.login-body {
min-height: 100%;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 15% -10%, rgba(251, 191, 36, 0.14), transparent 55%),
radial-gradient(700px 380px at 100% 10%, rgba(34, 197, 94, 0.1), transparent 50%),
linear-gradient(160deg, var(--bg0), var(--bg1) 55%, #0a1018);
}
.login-shell {
min-height: 100%;
display: grid;
place-items: center;
padding: max(1.25rem, env(safe-area-inset-top)) 1rem max(1.25rem, env(safe-area-inset-bottom));
}
.login-card {
width: min(420px, 100%);
background: rgba(17, 24, 33, 0.92);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.75rem 1.5rem 1.35rem;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
.login-logo {
display: block;
width: min(280px, 100%);
height: auto;
margin: 0 auto 0.75rem;
}
.login-sub {
margin: 0 0 1.25rem;
text-align: center;
color: var(--muted);
font-size: 0.92rem;
}
.login-error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
border-radius: 10px;
padding: 0.65rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.88rem;
}
.login-form { display: grid; gap: 0.85rem; }
.login-label {
display: grid;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.02em;
}
.login-input {
width: 100%;
min-height: 46px;
border-radius: 10px;
border: 1px solid var(--line);
background: #0a0f16;
color: var(--text);
padding: 0.65rem 0.8rem;
font-size: 16px;
}
.login-input:focus {
outline: none;
border-color: rgba(251, 191, 36, 0.55);
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.12);
}
.login-btn {
margin-top: 0.35rem;
min-height: 48px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: #111827;
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
.login-btn:hover { filter: brightness(1.05); }
.login-foot {
margin: 1.1rem 0 0;
text-align: center;
color: #64748b;
font-size: 0.75rem;
}
+323 -16
View File
@@ -1,5 +1,6 @@
/**
* Foodlinkk Command Center — mobile (Android + iOS)
* Must load LAST so it wins over app-sidebar-neo / herman-dashboard.
*/
:root {
--safe-top: env(safe-area-inset-top, 0px);
@@ -32,8 +33,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 998;
backdrop-filter: blur(2px);
z-index: 999;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@media (max-width: 1024px) {
@@ -87,34 +89,59 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
.regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; }
.regs-columns { flex-direction: column !important; display: flex !important; }
.pwa-install-bar { display: flex; }
/* Scherper op tablet/mobiel: minder soft blur */
.hm-panel, .hm-chart-panel, .live-digest, .briefing-card,
.ha-panel, .panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
@media (max-width: 768px) {
.mobile-menu-btn { display: inline-flex; }
.mobile-menu-btn { display: inline-flex !important; }
.mobile-nav-overlay { display: block; }
body:not(.nav-open) .mobile-nav-overlay { display: none; }
body:not(.nav-open) .mobile-nav-overlay { display: none !important; }
.app-shell {
grid-template-columns: 1fr !important;
display: block !important;
}
.app-main {
margin-left: 0 !important;
width: 100% !important;
max-width: 100vw !important;
}
/* Hard overrides — must beat app-sidebar-neo.css */
.app-sidebar {
position: fixed !important;
top: 0; left: 0; bottom: 0;
width: min(280px, 88vw);
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
width: min(280px, 88vw) !important;
height: 100dvh !important;
z-index: 999;
transform: translateX(-105%);
transition: transform 0.3s ease;
z-index: 1000 !important;
transform: translateX(-105%) !important;
transition: transform 0.3s ease !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
padding-top: max(1rem, var(--safe-top));
padding-top: max(1rem, var(--safe-top)) !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
overflow-y: auto;
overflow-x: hidden;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
}
body.nav-open .app-sidebar { transform: translateX(0); }
.app-sidebar-resizer { display: none !important; }
.persona-nav {
flex: none;
min-height: auto;
overflow: visible;
padding-bottom: 0;
}
body.nav-open .app-sidebar { transform: translateX(0) !important; }
body.nav-open { overflow: hidden; }
.app-topbar {
position: sticky; top: 0; z-index: 100;
padding-top: max(0.5rem, var(--safe-top));
@@ -124,16 +151,296 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
.retail-actions .btn, .herman-briefing-actions .btn {
min-width: 100%; flex: 1 1 100%;
}
.herman-briefing-actions {
flex-direction: column;
}
.herman-briefing-actions .btn {
width: 100%;
}
}
@media (max-width: 480px) {
.kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis {
grid-template-columns: 1fr !important;
}
.page-header h1 { font-size: 1.2rem !important; }
}
.pwa-install-bar {
display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 10100;
padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, var(--safe-bottom));
background: rgba(8, 12, 18, 0.95);
background: rgba(8, 12, 18, 0.97);
border-top: 1px solid rgba(148, 163, 184, 0.15);
align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;
}
.pwa-install-bar p { margin: 0; font-size: 0.85rem; color: #cbd5e1; flex: 1; }
@media (display-mode: standalone) { .pwa-install-bar { display: none !important; } }
.pwa-install-hint {
display: none;
width: 100%;
margin: 0;
font-size: 0.78rem;
color: #94a3b8;
line-height: 1.35;
}
.pwa-install-bar.is-ios .pwa-install-hint.is-ios-hint { display: block; }
.pwa-install-bar.is-android .pwa-install-hint.is-android-hint { display: block; }
.pwa-install-bar:not(.is-ios):not(.is-android) .pwa-install-hint.is-android-hint { display: block; }
@media (display-mode: standalone), (display-mode: fullscreen) {
.pwa-install-bar { display: none !important; }
}
/* ===== War Room FULL CHAT on phone (Android + iOS) ===== */
@media (max-width: 900px) {
/* Tabs: compact chip row */
.vtabs-layout {
display: flex !important;
flex-direction: column !important;
gap: 0.3rem !important;
min-height: 0 !important;
}
.vtabs-nav {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
width: 100% !important;
max-width: 100% !important;
position: sticky !important;
top: 0 !important;
z-index: 50 !important;
padding: 0.3rem !important;
margin: 0 !important;
}
.vtabs-nav .vtab-btn {
flex: 0 0 auto !important;
width: auto !important;
white-space: nowrap !important;
min-height: 40px !important;
padding: 0.35rem 0.6rem !important;
}
.vtabs-nav-header { display: none !important; }
.vtabs-content { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; min-height: 0 !important; }
/* Hide bulky page chrome in War Room */
.hm-neo.tab-warroom-active > .wr-page-header,
.hm-neo.tab-warroom-active > .page-header {
display: none !important;
}
.hm-neo.tab-warroom-active {
display: flex !important;
flex-direction: column !important;
height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
max-height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.hm-neo.tab-warroom-active .vtabs-layout {
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.hm-neo.tab-warroom-active .vtabs-content {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
/* Let Alpine x-show hide inactive tabs; only stretch the visible warroom section */
.hm-neo.tab-warroom-active .vtabs-content > section:not([style*="display: none"]) {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root > .panel {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
max-height: none !important;
padding: 0.35rem !important;
margin: 0 !important;
border-radius: 10px !important;
}
.wr-layout {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
height: 100% !important;
max-height: none !important;
min-height: 0 !important;
gap: 0.3rem !important;
}
.wr-mobile-bar {
display: flex !important;
flex: 0 0 auto;
gap: 0.3rem;
align-items: center;
justify-content: space-between;
}
.wr-layout.roster-collapsed .wr-roster { display: none !important; }
.wr-roster {
flex: 0 0 auto !important;
max-height: 18vh !important;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
border: 0 !important;
border-bottom: 1px solid rgba(148,163,184,0.15) !important;
padding: 0 0 0.25rem !important;
}
.wr-roster h3 { display: none !important; }
.wr-room-btn, .wr-agent-btn {
flex: 0 0 auto !important;
width: auto !important;
min-width: 6rem;
}
.wr-main {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: auto !important;
max-height: none !important;
}
/* Compact toolbar */
.wr-head {
flex: 0 0 auto !important;
margin: 0 !important;
gap: 0.25rem !important;
}
.wr-head-title h2 { font-size: 0.9rem !important; }
.wr-head-title .muted,
.wr-head-title .wr-msg-count { display: none !important; }
.wr-head-actions {
width: 100%;
gap: 0.25rem !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
.wr-head-actions .btn {
flex: 0 0 auto;
min-height: 36px !important;
padding: 0.3rem 0.45rem !important;
font-size: 0.75rem !important;
}
.wr-head-actions .wr-a2a-btn,
.wr-head-actions button[title="Open op tweede scherm"],
.wr-head-actions button[title="Spraakmeeting met agents"],
.wr-head-actions button[title="Snel praten"] {
display: none !important;
}
/* THE chat window — take all remaining space */
.wr-log {
flex: 1 1 auto !important;
min-height: 50dvh !important;
height: auto !important;
max-height: none !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
overscroll-behavior: contain;
padding: 0.5rem !important;
}
/* Readable bubbles */
.wr-bubble { gap: 0.4rem !important; margin-bottom: 0.55rem !important; }
.wr-avatar { width: 34px !important; height: 34px !important; flex-basis: 34px !important; }
.wr-avatar img, .wr-avatar .agent-char-wrap,
.wr-avatar .agent-char-wrap svg,
.wr-avatar-fallback { width: 34px !important; height: 34px !important; }
.wr-bubble-body {
max-width: calc(100% - 42px) !important;
width: auto !important;
padding: 0.45rem 0.55rem !important;
}
.wr-bubble-text {
font-size: 1rem !important;
line-height: 1.45 !important;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.wr-bubble-meta { font-size: 0.72rem !important; }
.wr-msg-reply, .wr-msg-del { opacity: 0.8 !important; }
.wr-composer {
flex: 0 0 auto !important;
position: relative !important;
bottom: auto !important;
z-index: 20;
margin-top: 0.25rem !important;
padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)) !important;
background: rgba(8, 12, 18, 0.98);
}
.wr-composer textarea,
.wr-composer .form-input {
font-size: 16px !important;
min-height: 48px !important;
line-height: 1.35 !important;
}
.wr-composer .btn-pulse-green {
min-height: 48px !important;
min-width: 4.5rem !important;
}
.wr-ceo-panel {
width: 100% !important;
max-height: 85dvh !important;
}
}
/* Less top padding around agents content on phone */
@media (max-width: 900px) {
.app-main .main-topnav {
padding-top: 0.45rem !important;
padding-bottom: 0.45rem !important;
}
body:has(.tab-warroom-active) .app-main .main-topnav .page-tip { display: none !important; }
}
@supports not (height: 100dvh) {
@media (max-width: 900px) {
.hm-neo.tab-warroom-active {
height: calc(100vh - 3.25rem) !important;
max-height: calc(100vh - 3.25rem) !important;
}
.wr-log { min-height: 50vh !important; }
}
}
/* reply-target mobile */
@media (max-width: 900px) {
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid #38bdf8;
}
.wr-msg-del { opacity: 0.35 !important; }
.wr-msg-reply { opacity: 1 !important; font-weight: 600; }
}
+118
View File
@@ -892,3 +892,121 @@ body:has(.topbar) .main { margin-left: 0; }
border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
}
.novnc-fallback a { color: var(--accent-cyan); }
/* ── AI / LLM settings ── */
.llm-hero-card {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding: 1.1rem 1.25rem;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(8, 12, 18, 0.95));
}
.llm-hero-body { flex: 1 1 280px; min-width: 0; }
.llm-hero-badge {
display: inline-block;
margin-bottom: 0.45rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(56, 189, 248, 0.25);
}
.llm-hero-card h3 {
margin: 0 0 0.4rem;
font-size: 1.05rem;
font-weight: 600;
color: #f1f5f9;
}
.llm-hero-lead {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: #94a3b8;
max-width: 42rem;
}
.llm-hero-lead code {
font-size: 0.8rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(15, 23, 42, 0.8);
color: #bae6fd;
}
.llm-hero-steps {
margin: 0.65rem 0 0;
padding-left: 1.15rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #94a3b8;
}
.llm-hero-steps a { color: #38bdf8; }
.llm-section-title {
margin: 0 0 0.25rem;
font-size: 0.92rem;
font-weight: 600;
color: #e2e8f0;
}
.llm-section-sub {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: #64748b;
}
.llm-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.65rem;
margin-bottom: 1.5rem;
}
.llm-preset-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
min-height: 7.5rem;
padding: 0.85rem 0.95rem;
text-align: left;
cursor: pointer;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.55);
color: inherit;
font: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.12s;
}
.llm-preset-card:hover {
border-color: rgba(56, 189, 248, 0.35);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.llm-preset-name {
font-size: 0.875rem;
font-weight: 600;
color: #f1f5f9;
line-height: 1.25;
}
.llm-preset-hint {
font-size: 0.75rem;
line-height: 1.4;
color: #64748b;
flex: 1;
}
.llm-preset-models {
font-size: 0.68rem;
font-family: ui-monospace, monospace;
color: #475569;
word-break: break-all;
line-height: 1.35;
}
.llm-provider-list { margin-top: 0.25rem; }
+517 -8
View File
@@ -16,10 +16,24 @@
font-size: 11pt;
}
.persona-ceo .main-topnav:has(.rc-excel-page),
.main-topnav:has(.rc-excel-page) {
.persona-ceo .main-topnav:has(.rc-page.rc-mode-sheet),
.main-topnav:has(.rc-page.rc-mode-sheet) {
background: #e8e8e8;
}
/* Cockpit-modus: altijd donker, nooit Excel-grijs */
.persona-ceo .main-topnav:has(.rc-page:not(.rc-mode-sheet)),
.main-topnav:has(.rc-page:not(.rc-mode-sheet)) {
background: transparent !important;
}
.rc-page:not(.rc-mode-sheet) {
background: transparent !important;
}
.rc-page:not(.rc-mode-sheet) .rc-header h1 {
color: #e2e8f0;
}
.rc-page:not(.rc-mode-sheet) .rc-subtitle {
color: #94a3b8;
}
.rc-toolbar {
display: flex;
@@ -371,12 +385,12 @@
}
/* 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-visual-row.rc-style-green td { box-shadow: none; }
.rc-visual-row.rc-style-red td { box-shadow: none; }
.rc-visual-row.rc-style-orange td { box-shadow: none; }
.rc-visual-row.rc-style-yellow td { box-shadow: none; color: #fef9c3; }
.rc-visual-row.rc-style-blue td { box-shadow: none; }
.rc-visual-row.rc-style-white td { box-shadow: none; }
.rc-vdeal { font-weight: 700; }
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -491,3 +505,498 @@
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
.rc-expand-inner { flex-direction: column; }
}
/* ── v10: leesbaarheid — géén witte achtergronden in cockpit ── */
.rc-page:not(.rc-mode-sheet) {
color: #e2e8f0;
}
.rc-cockpit .rc-visual-table,
.rc-cockpit .rc-visual-table td,
.rc-cockpit .rc-visual-table .rc-vcell {
background-color: #151c28 !important;
color: #e2e8f0 !important;
border-left: 0 !important;
border-right: 0 !important;
}
.rc-cockpit .rc-visual-head td {
background-color: #1a2332 !important;
color: #cbd5e1 !important;
}
.rc-cockpit .rc-visual-goals td {
background-color: #2a2410 !important;
color: #fde68a !important;
}
.rc-cockpit .rc-visual-row:hover td {
background-color: #1e2a3a !important;
}
.rc-cockpit .rc-visual-row.rc-row-open td {
background-color: #1a3050 !important;
}
.rc-cockpit .rc-deal-name,
.rc-cockpit .rc-vamount {
color: #f8fafc !important;
}
.rc-cockpit .rc-vamount { color: #86efac !important; }
.rc-cockpit .rc-vsteps { color: #cbd5e1 !important; }
.rc-cockpit .rc-expand-row td,
.rc-cockpit .rc-expand-inner {
background-color: #0a0e12 !important;
color: #e2e8f0 !important;
}
.rc-cockpit .rc-summary-strip {
background: #1a2332 !important;
color: #e2e8f0 !important;
}
.rc-cockpit .rc-sum-item strong { color: #86efac !important; }
.rc-cockpit .rc-sum-label { color: #94a3b8 !important; }
.rc-cockpit .rc-filter-search,
.rc-cockpit input[type="search"],
.rc-cockpit input[type="text"],
.rc-cockpit select {
background: #0f1419 !important;
color: #e2e8f0 !important;
border: 1px solid rgba(148,163,184,0.25) !important;
}
.rc-cockpit .rc-filter-search::placeholder {
color: #64748b !important;
}
/* Excel wit mag alleen in Bewerken-modus */
.rc-page:not(.rc-mode-sheet) .rc-btn,
.rc-page:not(.rc-mode-sheet) .rc-toggle-btn {
background: #1e293b;
color: #e2e8f0;
border-color: rgba(148,163,184,0.3);
}
.rc-page:not(.rc-mode-sheet) .rc-toggle-btn.active {
background: #14532d;
color: #bbf7d0;
border-color: #22c55e;
}
.rc-visual-row td + td { box-shadow: none !important; }
/* ── v11: wit vlak + verticale lijn weg ── */
.main-topnav:has(.rc-page:not(.rc-mode-sheet)),
.persona-ceo .main-topnav:has(.rc-page:not(.rc-mode-sheet)) {
background: transparent !important;
background-color: transparent !important;
}
.rc-page:not(.rc-mode-sheet),
.rc-page:not(.rc-mode-sheet) .rc-header,
.rc-page:not(.rc-mode-sheet) .rc-cockpit {
background: transparent !important;
background-color: transparent !important;
}
.rc-page:not(.rc-mode-sheet) .rc-header h1 { color: #f1f5f9 !important; }
.rc-page:not(.rc-mode-sheet) .rc-header .live-badge { color: #86efac; }
/* Geen statusstreep meer links in de tabel (= die witte verticale lijn) */
.rc-cockpit .rc-visual-row td,
.rc-cockpit .rc-visual-row td:first-child {
box-shadow: none !important;
border-left: 0 !important;
}
.rc-cockpit .rc-visual-wrap {
border: 1px solid rgba(148,163,184,0.12);
box-shadow: none;
}
/* Statuskleur als klein bolletje bij de dealnaam i.p.v. streep */
.rc-deal-name::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 0.45rem;
vertical-align: middle;
background: #64748b;
}
.rc-style-green .rc-deal-name::before { background: #4ade80; }
.rc-style-red .rc-deal-name::before { background: #f87171; }
.rc-style-orange .rc-deal-name::before { background: #fb923c; }
.rc-style-yellow .rc-deal-name::before { background: #facc15; }
.rc-style-blue .rc-deal-name::before { background: #60a5fa; }
.rc-style-white .rc-deal-name::before { background: #64748b; }
/* ── v12: selecteerbare visuals ── */
.rc-visual-toggle {
display: inline-flex;
border: 1px solid rgba(148,163,184,0.22);
border-radius: 8px;
overflow: hidden;
margin-left: 0.25rem;
}
.rc-vis-btn {
border: none;
background: #121a24;
color: #94a3b8;
padding: 0.32rem 0.7rem;
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.02em;
cursor: pointer;
}
.rc-vis-btn + .rc-vis-btn { border-left: 1px solid rgba(148,163,184,0.18); }
.rc-vis-btn.active {
background: #14532d;
color: #bbf7d0;
}
.rc-vis-btn:hover { color: #e2e8f0; }
.rc-style-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
.rc-chip {
border: 1px solid rgba(148,163,184,0.22);
background: #121a24;
color: #cbd5e1;
border-radius: 999px;
padding: 0.18rem 0.55rem;
font-size: 0.68rem;
font-weight: 650;
cursor: pointer;
line-height: 1.2;
}
.rc-chip:hover { border-color: rgba(148,163,184,0.45); }
.rc-chip.active {
box-shadow: 0 0 0 1px rgba(56,189,248,0.55);
color: #f8fafc;
}
.rc-chip-static { cursor: default; }
.rc-chip-green { border-color: rgba(74,222,128,0.45); color: #86efac; }
.rc-chip-red { border-color: rgba(248,113,113,0.45); color: #fca5a5; }
.rc-chip-orange { border-color: rgba(251,146,60,0.45); color: #fdba74; }
.rc-chip-yellow { border-color: rgba(250,204,21,0.45); color: #fde047; }
.rc-chip-blue { border-color: rgba(96,165,250,0.45); color: #93c5fd; }
.rc-chip-white { border-color: rgba(148,163,184,0.35); color: #94a3b8; }
.rc-chip-green.active { background: rgba(22,101,52,0.45); }
.rc-chip-red.active { background: rgba(127,29,29,0.4); }
.rc-chip-orange.active { background: rgba(154,52,18,0.4); }
.rc-chip-yellow.active { background: rgba(113,63,18,0.4); }
.rc-chip-blue.active { background: rgba(30,64,175,0.4); }
.rc-chip-white.active { background: rgba(51,65,85,0.5); }
.rc-goals-banner {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.55rem;
align-items: baseline;
margin-bottom: 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: #1a1520;
border: 1px solid rgba(250,204,21,0.22);
color: #fde68a;
font-size: 0.78rem;
line-height: 1.35;
}
.rc-goals-banner em {
margin-left: auto;
color: #facc15;
font-style: italic;
}
.rc-goals-sep { color: #78716c; }
/* Bars view */
.rc-bars-view {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: #0b1017;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 12px;
padding: 0.65rem;
}
.rc-bar-row {
display: block;
width: 100%;
text-align: left;
border: 1px solid rgba(148,163,184,0.1);
background: #141c28;
border-radius: 10px;
padding: 0.55rem 0.7rem;
cursor: pointer;
color: inherit;
}
.rc-bar-row:hover { border-color: rgba(148,163,184,0.28); background: #182232; }
.rc-bar-row.active { border-color: rgba(56,189,248,0.4); background: #152033; }
.rc-bar-row-top {
display: grid;
grid-template-columns: minmax(0,1fr) auto auto;
gap: 0.65rem;
align-items: center;
margin-bottom: 0.35rem;
}
.rc-bar-name {
font-size: 0.86rem;
font-weight: 700;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-bar-amt { font-weight: 800; color: #86efac; font-variant-numeric: tabular-nums; font-size: 0.86rem; }
.rc-bar-share { font-size: 0.72rem; color: #94a3b8; min-width: 2.5rem; text-align: right; }
.rc-bar-track {
height: 8px;
background: rgba(255,255,255,0.06);
border-radius: 999px;
overflow: hidden;
}
.rc-bar-fill { height: 100%; border-radius: 999px; min-width: 2px; }
/* Cards view */
.rc-cards-view {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 1100px) {
.rc-cards-view { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
.rc-cards-view { grid-template-columns: 1fr; }
}
.rc-card {
background: #141c28;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 12px;
padding: 0.75rem 0.85rem;
cursor: pointer;
min-height: 140px;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.rc-card:hover { border-color: rgba(148,163,184,0.3); background: #182232; }
.rc-card.active { border-color: rgba(56,189,248,0.4); background: #152033; }
.rc-card-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: flex-start;
}
.rc-card-head h3 {
margin: 0;
font-size: 0.9rem;
color: #f8fafc;
line-height: 1.3;
}
.rc-card-metrics {
display: flex;
gap: 0.85rem;
}
.rc-card-metrics div { display: flex; flex-direction: column; gap: 0.1rem; }
.rc-card-metrics span {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
}
.rc-card-metrics strong {
font-size: 0.92rem;
color: #86efac;
font-variant-numeric: tabular-nums;
}
.rc-card-bar {
height: 4px;
background: rgba(255,255,255,0.06);
border-radius: 999px;
overflow: hidden;
}
.rc-card-bar > div { height: 100%; border-radius: 999px; }
.rc-card-steps {
margin: 0;
margin-top: auto;
font-size: 0.75rem;
color: #94a3b8;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Detail panel */
.rc-detail {
margin-top: 0.85rem;
background: #0f1520;
border: 1px solid rgba(56,189,248,0.22);
border-radius: 14px;
padding: 0.9rem 1rem;
}
.rc-detail-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.rc-detail-head h3 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
color: #f8fafc;
}
.rc-detail-close {
border: 1px solid rgba(148,163,184,0.25);
background: #1e293b;
color: #cbd5e1;
border-radius: 8px;
width: 32px; height: 32px;
cursor: pointer;
}
.rc-detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr)) auto;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.75rem;
}
.rc-detail-stat span {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
margin-bottom: 0.2rem;
}
.rc-detail-stat strong {
font-size: 1.15rem;
color: #86efac;
font-variant-numeric: tabular-nums;
}
.rc-detail-ring {
width: 56px; height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.rc-detail-ring span {
font-size: 0.7rem;
font-weight: 800;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.55);
}
.rc-detail-compare {
display: flex;
gap: 1rem;
align-items: flex-end;
height: 72px;
margin-bottom: 0.85rem;
padding: 0.35rem 0.5rem;
background: rgba(8,12,18,0.55);
border-radius: 10px;
}
.rc-detail-compare-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
height: 100%;
}
.rc-detail-compare-col span { font-size: 0.62rem; color: #64748b; }
.rc-detail-vbar {
width: 28px;
min-height: 4px;
border-radius: 4px 4px 0 0;
background: #22c55e;
}
.rc-detail-vbar-year { background: #38bdf8; }
.rc-detail-status { margin-bottom: 0.75rem; }
.rc-detail-label {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 0.35rem;
}
.rc-detail-hint {
margin: 0.35rem 0 0;
font-size: 0.7rem;
color: #94a3b8;
}
.rc-detail-steps h4 {
margin: 0 0 0.3rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
.rc-detail-steps p {
margin: 0;
color: #e2e8f0;
font-size: 0.88rem;
line-height: 1.5;
white-space: pre-wrap;
}
.rc-detail-task { margin-top: 0.75rem; }
.rc-detail-task .rc-inline select,
.rc-detail-task .rc-inline input {
border: 1px solid rgba(148,163,184,0.25);
background: #0f1419 !important;
color: #e2e8f0 !important;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
border-radius: 4px;
}
.rc-summary-strip { gap: 0.65rem 0.85rem; }
.rc-style-dots { display: none !important; }
@media (max-width: 900px) {
.rc-detail-grid { grid-template-columns: 1fr 1fr; }
.rc-visual-toggle { order: 5; flex: 1 1 100%; }
.rc-style-chips { order: 6; flex: 1 1 100%; }
}
/* === MOBILE V2 === */
@media (max-width: 768px) {
.rc-sheet-wrap,
.rc-visual-wrap {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
.rc-sheet {
min-width: 720px !important; /* scroll inside wrap, don't blow up page */
}
.rc-visual-table {
min-width: 560px !important;
}
.rc-page {
overflow-x: hidden;
}
.rc-header {
flex-direction: column;
align-items: stretch !important;
}
.rc-header-actions {
width: 100%;
}
.rc-header-actions .rc-btn,
.rc-header-actions .rc-dot-btn,
.rc-view-toggle {
min-height: 44px;
}
.rc-summary-strip {
flex-direction: column;
align-items: stretch;
}
.rc-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.rc-filter-chips button,
.rc-toggle-btn {
min-height: 40px;
}
}
+147
View File
@@ -0,0 +1,147 @@
/* Topbar identity chip */
.app-user-chip {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-left: 0.65rem;
padding: 0.2rem 0.35rem 0.2rem 0.2rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.app-user-chip input[type="file"],
.user-avatar-input {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
.user-avatar-btn {
appearance: none;
border: 0;
padding: 0;
margin: 0;
background: transparent;
cursor: pointer;
border-radius: 50%;
line-height: 0;
flex-shrink: 0;
}
.user-avatar-btn:focus-visible {
outline: 2px solid rgba(251, 191, 36, 0.65);
outline-offset: 2px;
}
.user-avatar-img,
.user-avatar-fallback {
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(251, 191, 36, 0.55);
background: linear-gradient(145deg, #1e293b, #0f172a);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.user-avatar-img {
display: block;
}
.user-avatar-fallback {
display: none; /* Alpine x-show zet dit aan indien geen foto */
align-items: center;
justify-content: center;
color: #fbbf24;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Als Alpine display:flex/inline-flex zet via x-show */
.user-avatar-fallback[style*="flex"] {
display: inline-flex !important;
}
.user-avatar-meta {
display: flex;
flex-direction: column;
min-width: 0;
padding-right: 0.15rem;
line-height: 1.15;
}
.user-avatar-name {
font-size: 0.8rem;
font-weight: 600;
color: #e2e8f0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-avatar-role {
font-size: 0.65rem;
color: #94a3b8;
text-transform: capitalize;
}
.user-logout-btn {
min-height: 30px !important;
padding: 0.2rem 0.65rem !important;
font-size: 0.7rem !important;
border-radius: 999px !important;
margin-right: 0.15rem;
}
@media (max-width: 640px) {
.user-avatar-meta { display: none; }
.app-user-chip {
padding: 0.15rem;
gap: 0.35rem;
}
}
/* Settings list */
.user-card-avatar,
.user-form-avatar-preview {
width: 48px;
height: 48px;
border-radius: 50% !important;
object-fit: cover;
border: 2px solid rgba(251, 191, 36, 0.4);
background: #1e293b;
flex-shrink: 0;
}
.user-card-avatar.fallback,
.user-form-avatar-preview.fallback {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fbbf24;
font-weight: 700;
}
.user-form-avatar-preview {
width: 72px;
height: 72px;
}
.user-form-avatar-row {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
+52
View File
@@ -276,3 +276,55 @@
.vtab-btn.vtab-btn-rich .ei-vtab-text,
.vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
}
/* MOBILE TABS FIX — horizontal chip bar (Android + iOS) */
@media (max-width: 900px) {
.vtabs-layout {
flex-direction: column !important;
align-items: stretch !important;
gap: 0.5rem !important;
min-height: 0 !important;
}
.vtabs-layout > .vtabs-nav {
flex: 0 0 auto !important;
width: 100% !important;
max-width: none !important;
}
.vtabs-layout > .vtabs-content {
flex: 1 1 auto !important;
min-width: 0 !important;
width: 100% !important;
}
.vtabs-nav {
position: sticky !important;
top: 0 !important;
z-index: 40 !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
gap: 0.3rem !important;
padding: 0.4rem !important;
overflow-x: auto !important;
overflow-y: hidden !important;
-webkit-overflow-scrolling: touch !important;
scrollbar-width: none;
}
.vtabs-nav::-webkit-scrollbar { display: none; }
.vtabs-nav::before { display: none !important; }
.vtabs-nav-header { display: none !important; }
.vtab-btn {
flex: 0 0 auto !important;
width: auto !important;
max-width: none !important;
min-width: auto !important;
white-space: nowrap !important;
padding: 0.45rem 0.7rem !important;
font-size: 0.82rem !important;
}
.vtab-btn .vtab-icon,
.vtab-btn .vtab-icon-svg {
width: 26px !important;
height: 26px !important;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="Foodlinkk">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#f59e0b"/>
</linearGradient>
</defs>
<rect x="8" y="24" width="112" height="112" rx="28" fill="#0a0e14" stroke="url(#g)" stroke-width="4"/>
<text x="64" y="102" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="72" font-weight="700" fill="#fbbf24">F</text>
<circle cx="100" cy="44" r="10" fill="#22c55e"/>
<text x="140" y="88" font-family="system-ui,Segoe UI,sans-serif" font-size="52" font-weight="700" fill="#f8fafc" letter-spacing="-0.02em">Foodlinkk</text>
<text x="142" y="122" font-family="system-ui,Segoe UI,sans-serif" font-size="16" font-weight="500" fill="#94a3b8" letter-spacing="0.18em">COMMAND CENTER</text>
</svg>

After

Width:  |  Height:  |  Size: 925 B

+68 -52
View File
@@ -1,38 +1,48 @@
/** 3D-style agent poppetjes met headset — consistent team look */
/** Agent avatars — bright cool cyan/blue team look (readable on dark UI) */
window.AgentAvatars = (function () {
var CFG = {
herman: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'exec', accent: '#ffd700' },
marketing: { hoodie: '#9d174d', hair: '#1f2937', skin: '#f5c9a8', style: 'bob', accent: '#ff6b9d' },
bizdev: { hoodie: '#0e4d6e', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#00e5ff' },
finance: { hoodie: '#14532d', hair: '#3d2314', skin: '#e8b896', style: 'glasses', accent: '#22c55e' },
sourcing: { hoodie: '#9a3412', hair: '#1a1a1a', skin: '#c9a07a', style: 'short', accent: '#f97316' },
product: { hoodie: '#581c87', hair: '#4a3728', skin: '#f5c9a8', style: 'curl', accent: '#a855f7' },
halal: { hoodie: '#065f46', hair: '#1f2937', skin: '#d4a574', style: 'short', accent: '#34d399' },
design: { hoodie: '#831843', hair: '#111827', skin: '#f5c9a8', style: 'long', accent: '#ec4899' },
knowledge: { hoodie: '#312e81', hair: '#374151', skin: '#e8b896', style: 'glasses', accent: '#6366f1' },
retail: { hoodie: '#365314', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#b8ff3c' },
browser: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#c9a07a', style: 'short', accent: '#38bdf8' },
hr: { hoodie: '#6b21a8', hair: '#2d1810', skin: '#ddb896', style: 'bob', accent: '#e879f9' },
email: { hoodie: '#334155', hair: '#4a3728', skin: '#ddb896', style: 'short', accent: '#94a3b8' },
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' },
herman: { hoodie: '#1d4ed8', hair: '#5b3a29', skin: '#f0c9a8', style: 'exec', accent: '#fbbf24' },
marketing: { hoodie: '#0891b2', hair: '#1f2937', skin: '#f5d0b0', style: 'bob', accent: '#22d3ee' },
bizdev: { hoodie: '#0284c7', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#38bdf8' },
finance: { hoodie: '#0d9488', hair: '#3d2314', skin: '#f0c9a8', style: 'glasses', accent: '#2dd4bf' },
sourcing: { hoodie: '#0369a1', hair: '#1a1a1a', skin: '#e0b890', style: 'short', accent: '#7dd3fc' },
product: { hoodie: '#4f46e5', hair: '#5b3a29', skin: '#f5d0b0', style: 'curl', accent: '#818cf8' },
halal: { hoodie: '#059669', hair: '#1f2937', skin: '#e8c4a0', style: 'short', accent: '#34d399' },
design: { hoodie: '#0e7490', hair: '#111827', skin: '#f5d0b0', style: 'long', accent: '#67e8f9' },
knowledge: { hoodie: '#4338ca', hair: '#374151', skin: '#f0c9a8', style: 'glasses', accent: '#a5b4fc' },
retail: { hoodie: '#0f766e', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#5eead4' },
browser: { hoodie: '#2563eb', hair: '#1f2937', skin: '#e0b890', style: 'short', accent: '#60a5fa' },
hr: { hoodie: '#6366f1', hair: '#3d2314', skin: '#e8c4a0', style: 'bob', accent: '#c7d2fe' },
email: { hoodie: '#475569', hair: '#5b3a29', skin: '#e8c4a0', style: 'short', accent: '#94a3b8' },
research: { hoodie: '#0f766e', hair: '#1a1a1a', skin: '#f5d0b0', style: 'glasses', accent: '#2dd4bf' },
sysops: { hoodie: '#1e40af', hair: '#5b3a29', skin: '#f0c9a8', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#1d4ed8', hair: '#3d2314', skin: '#e0b890', style: 'short', accent: '#93c5fd' },
webbuilder: { hoodie: '#0284c7', hair: '#1f2937', skin: '#e8c4a0', style: 'glasses', accent: '#38bdf8', hat: 'cap' },
};
function cfg(key, fallbackColor) {
var k = (key || 'agent').toLowerCase();
var c = CFG[k] || { hoodie: fallbackColor || '#1e3a5f', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#38bdf8' };
var c = CFG[k] || { hoodie: fallbackColor || '#0284c7', hair: '#3d2314', skin: '#f0c9a8', style: 'short', accent: '#38bdf8' };
return c;
}
function lighten(hex, amt) {
hex = (hex || '#0284c7').replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var n = parseInt(hex, 16);
var r = Math.min(255, ((n >> 16) & 255) + amt);
var g = Math.min(255, ((n >> 8) & 255) + amt);
var b = Math.min(255, (n & 255) + amt);
return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function capSvg(hid, c) {
return (
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.35"/>' +
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + c.hoodie + '"/>' +
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="#1e40af"/>' +
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="#38bdf8" stroke-width="1.2" opacity="0.5"/>' +
'<rect x="46" y="28" width="8" height="5" rx="1" fill="#38bdf8" opacity="0.85"/>'
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.2"/>' +
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + lighten(c.hoodie, 40) + '"/>' +
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="' + c.hoodie + '"/>' +
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="' + c.accent + '" stroke-width="1.4" opacity="0.7"/>' +
'<rect x="46" y="28" width="8" height="5" rx="1" fill="' + c.accent + '"/>'
);
}
@@ -45,64 +55,70 @@ window.AgentAvatars = (function () {
function svgAvatar(key, color) {
var c = cfg(key, color);
var hid = 'av-' + key;
var hid = 'av-' + key + '-' + Math.random().toString(36).slice(2, 7);
var hoodLite = lighten(c.hoodie, 55);
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" class="agent-char-svg" aria-hidden="true">' +
'<defs>' +
'<linearGradient id="' + hid + '-bg" x1="0%" y1="0%" x2="100%" y2="100%">' +
'<stop offset="0%" stop-color="#0c1929"/><stop offset="50%" stop-color="#152238"/><stop offset="100%" stop-color="#0a1020"/>' +
'<stop offset="0%" stop-color="#1a3a5c"/><stop offset="55%" stop-color="#245a82"/><stop offset="100%" stop-color="#1e4a6e"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-hood" x1="30%" y1="0%" x2="70%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.hoodie + '"/><stop offset="100%" stop-color="#0a0f18"/>' +
'<stop offset="0%" stop-color="' + hoodLite + '"/><stop offset="100%" stop-color="' + c.hoodie + '"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-skin" x1="40%" y1="0%" x2="60%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#c9956e"/>' +
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#d4a07a"/>' +
'</linearGradient>' +
'<radialGradient id="' + hid + '-glow" cx="75%" cy="25%" r="45%">' +
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.35"/><stop offset="100%" stop-opacity="0"/>' +
'<radialGradient id="' + hid + '-glow" cx="70%" cy="20%" r="50%">' +
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.55"/><stop offset="100%" stop-opacity="0"/>' +
'</radialGradient>' +
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.45"/></filter>' +
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-opacity="0.3"/></filter>' +
'</defs>' +
'<rect width="100" height="120" fill="url(#' + hid + '-bg)"/>' +
'<ellipse cx="78" cy="28" rx="35" ry="30" fill="url(#' + hid + '-glow)"/>' +
'<ellipse cx="50" cy="100" rx="30" ry="14" fill="rgba(0,0,0,0.4)"/>' +
'<rect width="100" height="120" rx="14" fill="url(#' + hid + '-bg)"/>' +
'<ellipse cx="78" cy="26" rx="38" ry="32" fill="url(#' + hid + '-glow)"/>' +
'<ellipse cx="50" cy="100" rx="28" ry="12" fill="rgba(0,0,0,0.22)"/>' +
'<path d="M18,118 Q18,74 50,68 Q82,74 82,118Z" fill="url(#' + hid + '-hood)" filter="url(#' + hid + '-sh)"/>' +
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + c.hoodie + '" opacity="0.9"/>' +
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + hoodLite + '" opacity="0.95"/>' +
'<path d="M28,68 Q50,48 72,68 L68,78 Q50,72 32,78Z" fill="' + c.hoodie + '"/>' +
'<ellipse cx="50" cy="54" rx="23" ry="25" fill="url(#' + hid + '-skin)" filter="url(#' + hid + '-sh)"/>' +
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(0,0,0,0.08)"/>' +
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(255,255,255,0.12)"/>' +
(c.hat === 'cap' ? '' : '<path d="' + hairPath(c.style) + '" fill="' + c.hair + '"/>') +
(c.style === 'glasses'
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<line x1="50" y1="52" x2="50" y2="52" stroke="#334155" stroke-width="1.5"/>'
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#e2e8f0" stroke-width="1.6"/>' +
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#e2e8f0" stroke-width="1.6"/>' +
'<line x1="50" y1="52" x2="50" y2="52" stroke="#e2e8f0" stroke-width="1.5"/>'
: '') +
'<ellipse cx="42" cy="52" rx="3" ry="3.5" fill="#1e293b"/><ellipse cx="58" cy="52" rx="3" ry="3.5" fill="#1e293b"/>' +
'<ellipse cx="43" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/><ellipse cx="59" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/>' +
'<ellipse cx="42" cy="52" rx="3.2" ry="3.6" fill="#0f172a"/><ellipse cx="58" cy="52" rx="3.2" ry="3.6" fill="#0f172a"/>' +
'<ellipse cx="43.2" cy="50.8" rx="1.1" ry="1.3" fill="#fff" opacity="0.85"/><ellipse cx="59.2" cy="50.8" rx="1.1" ry="1.3" fill="#fff" opacity="0.85"/>' +
'<path d="M44,62 Q50,66 56,62" fill="none" stroke="#b45309" stroke-width="1.5" stroke-linecap="round"/>' +
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#cbd5e1" stroke-width="4" stroke-linecap="round"/>' +
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#e2e8f0" stroke-width="3.5" stroke-linecap="round"/>' +
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="' + c.accent + '" stroke-width="1.8"/>' +
'<rect x="12" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="' + c.accent + '" stroke-width="1.8"/>' +
'<rect x="78" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#94a3b8" stroke-width="2.8" stroke-linecap="round"/>' +
'<circle cx="82" cy="84" r="3.5" fill="#64748b" stroke="#94a3b8" stroke-width="1"/>' +
'<circle cx="88" cy="32" r="2.5" fill="' + c.accent + '" opacity="0.9"/>' +
'<circle cx="12" cy="40" r="1.5" fill="' + c.accent + '" opacity="0.5"/>' +
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#cbd5e1" stroke-width="2.6" stroke-linecap="round"/>' +
'<circle cx="82" cy="84" r="3.5" fill="' + c.accent + '" stroke="#e2e8f0" stroke-width="1"/>' +
'<circle cx="88" cy="32" r="3" fill="' + c.accent + '"/>' +
'<circle cx="14" cy="38" r="2" fill="' + c.accent + '" opacity="0.7"/>' +
(c.hat === 'cap' ? capSvg(hid, c) : '') +
'</svg>'
);
}
function paint(el, key, color) {
function paint(el, key, color, force) {
if (!el) return;
el.innerHTML = svgAvatar((key || '').toLowerCase(), color);
var k = (key || '').toLowerCase();
var c = color || '';
var sig = k + '|v2|' + c;
if (!force && el.getAttribute('data-avatar-painted') === sig && el.querySelector('svg')) return;
el.innerHTML = svgAvatar(k, color);
el.setAttribute('data-avatar-painted', sig);
}
function paintAll(root) {
function paintAll(root, force) {
root = root || document;
root.querySelectorAll('[data-agent-avatar]').forEach(function (el) {
paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color'));
paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color'), force);
});
}
File diff suppressed because it is too large Load Diff
+202 -172
View File
@@ -1,199 +1,229 @@
window.AnalyticsCharts = (function () {
var charts = {};
var colors = {
gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)',
green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)',
purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0',
};
var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray];
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function barChart(id, labels, values, label, horizontal) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }],
},
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } },
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
},
},
});
}
function doughnut(id, labels, values) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'doughnut',
data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } },
});
}
function lineChart(id, labels, values) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
},
},
});
}
function renderKpis(container, kpis) {
if (!container || !kpis) return;
var items = [
['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active],
['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')],
['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers],
['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items],
['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns],
['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals],
['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs],
];
container.innerHTML = items.map(function (it) {
return '<div class="kpi-card"><span>' + it[0] + '</span><strong>' + it[1] + '</strong></div>';
}).join('');
}
function renderTables(data) {
var dealsEl = document.getElementById('analytics-deals-table');
var eventsEl = document.getElementById('analytics-events-table');
if (dealsEl) {
dealsEl.innerHTML = (data.recent_deals || []).map(function (d) {
return '<tr><td>' + (d.title || '—') + '</td><td>' + (d.stage || '') + '</td><td>€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + '</td></tr>';
}).join('') || '<tr><td colspan="3">Geen deals</td></tr>';
}
if (eventsEl) {
eventsEl.innerHTML = (data.recent_events || []).map(function (e) {
var t = (e.created_at || '').substring(0, 16).replace('T', ' ');
return '<tr><td>' + (e.agent_name || '') + '</td><td>' + (e.title || e.event_type || '') + '</td><td>' + t + '</td></tr>';
}).join('') || '<tr><td colspan="3">Geen events</td></tr>';
}
}
function render(data, mode) {
if (!data) return;
mode = mode || window.__vizMode || 'neo-bars';
window.__analyticsLastData = data;
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
var useRing = mode === 'neo-rings';
var bar = function (id, labels, values, label, horizontal) {
if (useRing) doughnut(id, labels, values);
else barChart(id, labels, values, label, horizontal);
};
bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
(data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR');
bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
(data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten');
bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
(data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
if (useRing) {
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
} else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
(data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true);
if (mode === 'neo-table') {
lineChart('chart-timeline', [], []);
} else {
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
}
doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }),
(data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
(data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH');
doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }),
(data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
(data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true);
bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
(data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true);
bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
(data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo');
doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }),
(data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
renderTables(data);
}
document.addEventListener('foodlinkk:vizmode', function (ev) {
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
});
return { render: render, destroyAll: destroyAll };
})();
/**
* Foodlinkk Intelligence Analytics hub
*/
function analyticsHub() {
const charts = {};
function destroyCharts() {
Object.keys(charts).forEach((k) => {
try { charts[k].destroy(); } catch (e) {}
delete charts[k];
});
}
function euro(n) {
const v = Math.round(Number(n || 0));
return '€' + v.toLocaleString('nl-NL');
}
return {
loading: false,
showFilters: false,
updatedAt: '—',
liveLabel: 'Live',
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
meta: {},
data: {},
kpis: {},
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
findings: [],
goals: {},
signals: [],
_pollStop: null,
init() {
var initial = window.ANALYTICS_INITIAL || {};
this.meta = initial.filter_meta || {};
AnalyticsCharts.render(initial);
this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' ');
this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000);
fmt(n) {
return Number(n || 0).toLocaleString('nl-NL');
},
formatTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return String(iso).slice(0, 16).replace('T', ' ');
}
},
shortGoal(t) {
const s = String(t || '').replace(/\s+/g, ' ').trim();
return s.length > 90 ? s.slice(0, 87) + '…' : s;
},
ringDash(score) {
const s = Math.max(0, Math.min(100, Number(score || 0)));
const c = 2 * Math.PI * 52;
return ((s / 100) * c).toFixed(1);
},
applyData(payload) {
this.data = payload || {};
this.kpis = this.data.kpis || {};
this.pulse = this.data.pulse || { wins: [], opportunities: [], attention: [], score: 0 };
this.findings = this.data.hermes_findings || [];
this.goals = this.data.goals || {};
this.meta = this.data.filter_meta || this.meta;
this.updatedAt = (this.data.generated_at || '').substring(0, 19).replace('T', ' ') || '—';
this.signals = [
{ key: 'margin', label: 'Marge / mnd', value: euro(this.kpis.margin_month), hint: (this.kpis.active_projects || 0) + ' deals', tone: 'good' },
{ key: 'partners', label: 'CRM actief', value: this.fmt(this.kpis.crm_partnerships), hint: (this.pulse.cover_pct || 0) + '% dekking', tone: 'good' },
{ key: 'gap', label: 'White space', value: this.fmt(this.pulse.white_space), hint: 'winkels zonder partnership', tone: 'warn' },
{ key: 'stores', label: 'Supermarkten', value: this.fmt(this.kpis.supermarkets), hint: this.fmt(this.kpis.wholesalers) + ' groothandels' },
{ key: 'rss', label: 'Markt signalen', value: this.fmt(this.kpis.rss_items), hint: this.fmt(this.kpis.promo_campaigns) + ' promo’s' },
{ key: 'agents', label: 'Agent events', value: this.fmt(this.kpis.agent_events), hint: this.fmt(this.kpis.pending_approvals) + ' approvals', tone: this.kpis.pending_approvals ? 'warn' : '' },
];
this.$nextTick(() => this.renderCharts());
},
init() {
this.applyData(window.ANALYTICS_INITIAL || {});
if (window.CockpitLive && CockpitLive.startPolling) {
this._pollStop = CockpitLive.startPolling(() => this.refresh(false), 45000);
} else {
this._pollStop = setInterval(() => this.refresh(false), 45000);
}
},
params() {
var p = new URLSearchParams();
Object.entries(this.f).forEach(function (e) {
if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]);
const p = new URLSearchParams();
Object.entries(this.f).forEach(([k, v]) => {
if (v !== null && v !== '' && v !== undefined) p.set(k, v);
});
return p.toString();
},
async refresh(toast) {
this.loading = true;
try {
var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); });
this.meta = data.filter_meta || this.meta;
AnalyticsCharts.render(data);
this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' ');
if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success');
const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json());
this.applyData(data);
if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success');
} catch (e) {
if (toast !== false) Cockpit.toast(e.message, 'error');
if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
} finally {
this.loading = false;
}
},
resetFilters() {
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
this.refresh();
},
renderCharts() {
if (typeof Chart === 'undefined') return;
destroyCharts();
const grid = 'rgba(148,163,184,0.12)';
const tick = '#94a3b8';
const d = this.data;
const mk = (id, cfg) => {
const el = document.getElementById(id);
if (!el) return;
charts[id] = new Chart(el, cfg);
};
mk('ax-chart-timeline', {
type: 'line',
data: {
labels: (d.events_timeline || []).map((r) => String(r.day || '').substring(5)),
datasets: [{
data: (d.events_timeline || []).map((r) => Number(r.cnt || 0)),
borderColor: '#38bdf8',
backgroundColor: 'rgba(56,189,248,0.15)',
fill: true,
tension: 0.35,
pointRadius: 0,
borderWidth: 2,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick, maxTicksLimit: 10 }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid } },
},
},
});
mk('ax-chart-partners', {
type: 'doughnut',
data: {
labels: (d.partnership_breakdown || []).map((r) => r.status),
datasets: [{
data: (d.partnership_breakdown || []).map((r) => Number(r.cnt || 0)),
backgroundColor: ['#64748b', '#b8ff3c', '#fbbf24', '#38bdf8', '#fb7185'],
borderWidth: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { color: tick, boxWidth: 10, font: { size: 10 } } } },
},
});
mk('ax-chart-opps', {
type: 'bar',
data: {
labels: (d.top_opportunities || []).slice(0, 8).map((r) => (r.chain || '') + ' ' + (r.city || '')),
datasets: [{
data: (d.top_opportunities || []).slice(0, 8).map((r) => Math.round(Number(r.halal_opportunity_score || 0))),
backgroundColor: 'rgba(251,191,36,0.55)',
borderColor: '#fbbf24',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
mk('ax-chart-chains', {
type: 'bar',
data: {
labels: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => r.chain),
datasets: [{
data: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
backgroundColor: 'rgba(56,189,248,0.45)',
borderColor: '#38bdf8',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
mk('ax-chart-prov', {
type: 'bar',
data: {
labels: (d.supermarkets_by_province || []).slice(0, 8).map((r) => r.province),
datasets: [{
data: (d.supermarkets_by_province || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
backgroundColor: 'rgba(184,255,60,0.4)',
borderColor: '#b8ff3c',
borderWidth: 1,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick, maxRotation: 45 }, grid: { display: false } },
y: { ticks: { color: tick }, grid: { color: grid } },
},
},
});
},
};
}
+248 -205
View File
@@ -26,6 +26,8 @@ window.BriefingCharts = (function () {
function typewriter(el, text, speed) {
if (!el) return;
el.textContent = text || '';
return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
@@ -58,16 +60,81 @@ window.BriefingCharts = (function () {
}
function renderPipeline(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
if (charts.pipeline) charts.pipeline.destroy();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } },
});
// Compact HTML bars — geen vaste canvas-hoogte / geen Chart.js
if (!canvas) return;
if (charts.pipeline) {
try { charts.pipeline.destroy(); } catch (e) {}
charts.pipeline = null;
}
var host = canvas.parentElement;
if (!host) return;
canvas.style.display = 'none';
canvas.setAttribute('hidden', '');
var panel = host.querySelector('.hm-pipeline-bars');
if (!panel) {
panel = document.createElement('div');
panel.className = 'hm-pipeline-bars';
host.appendChild(panel);
}
var rev = (stats.revenue && stats.revenue.stats) || {};
var crmRows = stats.pipeline_by_stage || stats.deal_stages || [];
var crmTotal = Math.round(stats.pipeline_eur || 0);
var rows = [];
// Prefer revenue top deals when CRM pipeline is empty/useless
var projects = (stats.revenue && (stats.revenue.projects || stats.revenue.top_projects)) || [];
if (!projects.length && stats.revenue && stats.revenue.by_brand) {
var bb = stats.revenue.by_brand;
projects = [].concat(bb.cucina || [], bb.foodlinkk || [], bb.shared || [], bb.other || []);
}
if ((!crmRows || !crmRows.length || crmTotal === 0) && projects.length) {
rows = projects
.filter(function (p) { return Number(p.margin_month || 0) > 0; })
.sort(function (a, b) { return Number(b.margin_month || 0) - Number(a.margin_month || 0); })
.slice(0, 6)
.map(function (p) {
return { name: p.name || 'Deal', count: 1, value: Number(p.margin_month || 0) };
});
var sub = document.getElementById('ceo-pipeline-sub');
if (sub) sub.textContent = 'top marge/mnd · Revenue';
} else if (crmRows && crmRows.length) {
rows = crmRows.map(function (r) {
return {
name: r.stage || r.name || 'Stage',
count: r.count || r.deals || 0,
value: Number(r.value || r.eur || r.pipeline_eur || 0),
};
});
var sub2 = document.getElementById('ceo-pipeline-sub');
if (sub2) sub2.textContent = 'CRM stages';
}
if (!rows.length) {
var dealCount = stats.deals || rev.active_deals || 0;
panel.innerHTML =
'<div class="hm-pipeline-head"><strong>' + dealCount + ' deals</strong><span>geen stage-waarde</span></div>' +
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
return;
}
var maxV = Math.max.apply(null, rows.map(function (r) { return r.value || 0 }).concat([1]));
var totalEur = rows.reduce(function (s, r) { return s + (r.value || 0); }, 0);
var totalCnt = rows.reduce(function (s, r) { return s + (r.count || 0); }, 0);
panel.innerHTML =
'<div class="hm-pipeline-head">' +
'<strong>€' + Math.round(totalEur).toLocaleString('nl-NL') + '</strong>' +
'<span>' + (totalCnt || rows.length) + ' regels</span>' +
'</div>' +
'<div class="hm-pipeline-list">' + rows.map(function (r) {
var pct = Math.max(4, Math.round(((r.value || 0) / maxV) * 100));
return '<div class="hm-pipeline-row">' +
'<div class="hm-pipeline-meta"><span class="hm-pipeline-stage">' + String(r.name).replace(/</g, '') + '</span>' +
'<span class="hm-pipeline-vals">€' + Math.round(r.value || 0).toLocaleString('nl-NL') + '</span></div>' +
'<div class="hm-pipeline-track"><div class="hm-pipeline-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}).join('') + '</div>' +
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
}
function renderSentiment(canvas, stats) {
@@ -236,6 +303,22 @@ window.BriefingCharts = (function () {
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
case 'margin_month': {
var rev = (stats.revenue && stats.revenue.stats) || {};
return '€' + Math.round(rev.total_margin_month || 0).toLocaleString('nl-NL');
}
case 'margin_year': {
var revY = (stats.revenue && stats.revenue.stats) || {};
return '€' + Math.round(revY.total_margin_year || 0).toLocaleString('nl-NL');
}
case 'open_steps': {
var revO = (stats.revenue && stats.revenue.stats) || {};
return String(revO.open_objectives || 0);
}
case 'revenue_deals': {
var revD = (stats.revenue && stats.revenue.stats) || {};
return String(revD.active_deals || revD.active_projects || 0);
}
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
@@ -260,6 +343,13 @@ window.BriefingCharts = (function () {
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '72%';
case 'margin_month': return '90%';
case 'margin_year': return '85%';
case 'open_steps': {
var ro = (stats.revenue && stats.revenue.stats) || {};
return Math.min(95, Math.round((ro.open_objectives || 0) / 50 * 100)) + '%';
}
case 'revenue_deals': return '80%';
case 'clients_active':
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
case 'crm_partnerships': return '45%';
@@ -276,17 +366,22 @@ window.BriefingCharts = (function () {
if (!container) return;
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
var order = selectedKpis || window._dashboardKpis ||
(window.DashboardLayout && window.DashboardLayout.CEO_KPIS) ||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
if (window.DashboardLayout && window.DashboardLayout.ceoKpis) {
order = window.DashboardLayout.ceoKpis({ dashboard_kpis: order });
}
order = order.slice(0, 6);
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
id: id, label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
};
});
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
container.innerHTML = '<p class="empty-state">Geen KPI\'s</p>';
return;
}
container.className = 'hm-neo-kpi-row';
@@ -299,6 +394,7 @@ window.BriefingCharts = (function () {
}).join('');
}
function renderRssFeed(container, stats) {
if (!container) return;
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
@@ -347,67 +443,94 @@ window.BriefingCharts = (function () {
function renderExecutiveSummary(container, stats) {
if (!container) return;
var items = [
{ icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
{ icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
{ icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
{ icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
{ icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
{ icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
ms.forEach(function (m) {
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
var rev = (stats.revenue && stats.revenue.stats) || {};
var by = (stats.revenue && stats.revenue.by_brand) || {};
var cu = by.cucina || [];
var fl = by.foodlinkk || [];
var clients = stats.recent_clients || [];
var opp = stats.top_opportunities || [];
var deals = stats.deals || 0;
var pipeline = Math.round(stats.pipeline_eur || 0);
var marginM = Math.round(rev.total_margin_month || 0);
var openObj = rev.open_objectives || 0;
// Narrative context
var narrative = [];
if (marginM > 0) narrative.push('Cucina + Foodlinkk draaien €' + marginM.toLocaleString('nl-NL') + '/mnd marge over ' + (rev.active_projects || 0) + ' projecten.');
if (openObj) narrative.push(openObj + ' open stappen vragen opvolging.');
if ((stats.crm_partnerships || 0) > 0) narrative.push(stats.crm_partnerships + ' retail-partnerships · ' + (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen bereikbaar.');
if (pipeline === 0 && deals > 0) narrative.push(deals + ' CRM-deals staan klaar — pipeline-waarde nog €0 (nog geen getekende budgetten).');
if (!narrative.length) narrative.push('Live business data laden…');
var topDeals = cu.filter(function (p) { return p.margin_month; }).slice(0, 3);
var html = '';
html += '<div class="hm-context-banner"><p>' + escHtml(narrative.join(' ')) + '</p></div>';
html += '<div class="hm-exec-grid hm-exec-grid-rich">' +
[
{ icon: '💶', label: 'Marge / mnd', val: '€' + marginM.toLocaleString('nl-NL'), sub: (rev.active_projects || 0) + ' projecten', link: '/revenue-cockpit' },
{ icon: '💰', label: 'CRM pipeline', val: '€' + pipeline.toLocaleString('nl-NL'), sub: deals + ' deals', link: '/deals' },
{ icon: '🤝', label: 'Klanten', val: String(stats.clients_total || stats.clients || 0), sub: (stats.clients_active || 0) + ' actief', link: '/clients' },
{ icon: '🏪', label: 'Partnerships', val: String(stats.crm_partnerships || 0), sub: (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen', link: '/retail' },
].map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item hm-exec-rich">' +
'<span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div>' +
'<div class="hm-exec-sub">' + it.sub + '</div></div></a>';
}).join('') + '</div>';
if (topDeals.length) {
html += '<div class="hm-context-block"><h4>Top Cucina deals</h4><div class="hm-deal-chips">';
topDeals.forEach(function (d) {
html += '<a class="hm-deal-chip" href="/revenue-cockpit"><strong>' + escHtml(d.name) + '</strong>' +
'<span>€' + Math.round(d.margin_month || 0).toLocaleString('nl-NL') + '/m</span>' +
(d.open_objectives ? '<em>' + d.open_objectives + ' stappen</em>' : '') + '</a>';
});
html += '</ul>';
html += '</div></div>';
}
var pending = stats.pending_approval_requests || [];
if (pending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
pending.forEach(function (p) {
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
if (clients.length) {
html += '<div class="hm-context-block"><h4>Recente klanten</h4><div class="hm-deal-chips">';
clients.slice(0, 4).forEach(function (c) {
html += '<a class="hm-deal-chip hm-deal-chip-client" href="/clients"><strong>' + escHtml(c.name || '') + '</strong>' +
'<span>' + escHtml(c.stage || '') + '</span></a>';
});
html += '</ul>';
html += '</div></div>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
if (opp.length) {
html += '<div class="hm-context-block"><h4>Halal-kansen</h4><ul class="hm-context-list">';
opp.slice(0, 3).forEach(function (o) {
var score = Math.round(o.halal_opportunity_score || o.score || 0);
html += '<li><strong>' + escHtml(o.chain || o.name || '') + '</strong> · ' +
escHtml(o.city || '') + ' <span class="hm-score">score ' + score + '</span></li>';
});
html += '</ul></div>';
}
html += '<p class="hm-exec-footer">' +
'<a href="/revenue-cockpit">Revenue Cockpit</a> · <a href="/retail">Retail 360</a> · ' +
'<a href="/clients">Clients</a> · <a href="/deals">Deals</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl && parsed.summary) {
var lines = parsed.summary.split(/(?<=[.!?])\s+/).filter(Boolean);
typewriterLiveDigest(sumEl, {
sections: [{ brand: 'platform', title: 'Herman rapport', icon: '📝', lines: lines.length ? lines : [parsed.summary] }],
}, { charMs: 10, linePause: 90 });
}
var actEl = document.getElementById('briefing-actions-list');
if (actEl) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) setTimeout(function () {
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
}, 300);
if (!sumEl || !content) return;
sumEl.dataset.aiMemo = '1';
// Markdown-achtige tekst direct tonen (geen typewriter)
var html = escHtml(content)
.replace(/^### (.+)$/gm, '<h5>$1</h5>')
.replace(/^## (.+)$/gm, '<h4>$1</h4>')
.replace(/^# (.+)$/gm, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, function (m) { return '<ul class="live-digest-list">' + m + '</ul>'; })
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
sumEl.innerHTML = '<div class="hm-ceo-memo-md"><p>' + html + '</p></div>';
}
function escHtml(s) {
@@ -441,152 +564,77 @@ window.BriefingCharts = (function () {
}
function typewriterLiveDigest(container, digest, opts) {
if (!container) return;
opts = opts || {};
var charMs = opts.charMs || 14;
var linePause = opts.linePause || 120;
var sectionPause = opts.sectionPause || 200;
if (typewriterAbort) { typewriterAbort(); typewriterAbort = null; }
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
var sections = (digest && digest.sections) || [];
if (!sections.length) {
var summary = (digest && digest.summary) || '';
if (summary) {
typewriter(container, summary, charMs);
} else {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
}
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function () { return '<li class="tw-line"></li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5 class="tw-heading">' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
var queue = [];
container.querySelectorAll('.live-digest-section').forEach(function (secEl, si) {
var lines = (sections[si] && sections[si].lines) || [];
var lis = secEl.querySelectorAll('li.tw-line');
for (var i = 0; i < lis.length; i++) {
queue.push({ el: lis[i], text: lines[i] || '', section: si });
}
});
var cancelled = false;
typewriterAbort = function () { cancelled = true; };
container.classList.add('is-typing');
function tick(qi, ci, prevSection) {
if (cancelled) return;
if (qi >= queue.length) {
container.classList.remove('is-typing');
container.querySelectorAll('.is-typing').forEach(function (n) { n.classList.remove('is-typing'); });
return;
}
var item = queue[qi];
var el = item.el;
var text = item.text;
if (prevSection !== item.section && ci === 0) {
setTimeout(function () { if (!cancelled) tick(qi, ci, item.section); }, sectionPause);
return;
}
el.classList.add('is-typing');
container.querySelectorAll('li.is-typing').forEach(function (n) { if (n !== el) n.classList.remove('is-typing'); });
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
setTimeout(function () { tick(qi, ci + 1, item.section); }, charMs);
} else {
el.classList.remove('is-typing');
setTimeout(function () { tick(qi + 1, 0, item.section); }, linePause);
}
}
tick(0, 0, -1);
// Typewriter uit — altijd direct tonen
fillDigestInstant(container, digest);
}
function typewriterListItems(listEl, items, opts) {
if (!listEl || !items || !items.length) return;
opts = opts || {};
var charMs = opts.charMs || 10;
listEl.innerHTML = items.map(function () { return '<li class="tw-line"></li>'; }).join('');
var lis = listEl.querySelectorAll('li.tw-line');
var qi = 0, ci = 0, cancelled = false;
function next() {
if (cancelled || qi >= items.length) return;
var el = lis[qi];
var text = items[qi];
el.classList.add('is-typing');
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
ci++;
setTimeout(next, charMs);
} else {
el.classList.remove('is-typing');
qi++; ci = 0;
setTimeout(next, 90);
}
}
next();
return function () { cancelled = true; };
fillListInstant(listEl, items || []);
}
function fillListInstant(listEl, items) {
if (!listEl) return;
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
}
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var sig = digestSignature(digest);
var unchanged = pollRefresh && sig === lastDigestSig;
if (!unchanged) lastDigestSig = sig;
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
}
if (unchanged) {
if (actEl) fillListInstant(actEl, digest.actions || []);
if (longEl) fillListInstant(longEl, digest.long_term || []);
function fillSimpleList(el, items, emptyMsg) {
if (!el) return;
var rows = (items || []).filter(Boolean);
if (!rows.length) {
el.innerHTML = '<li class="muted">' + escHtml(emptyMsg || 'Geen items') + '</li>';
return;
}
el.innerHTML = rows.map(function (x) {
if (typeof x === 'string') return '<li><span class="hm-ceo-bullet">→</span> ' + escHtml(x) + '</li>';
var title = escHtml(x.title || x);
var link = x.link ? ('<a href="' + escHtml(x.link) + '" target="_blank" rel="noopener">' + title + '</a>') : title;
var src = x.source ? ('<small>' + escHtml(x.source) + '</small>') : '';
return '<li>' + link + src + '</li>';
}).join('');
}
var isUpdate = !!lastDigestSig && !pollRefresh;
var twOpts = { charMs: isUpdate ? 8 : 12, linePause: isUpdate ? 70 : 100, sectionPause: isUpdate ? 120 : 180 };
function fillBrandCard(el, lines) {
if (!el) return;
var list = el.querySelector('.hm-ceo-brand-list') || el;
var rows = (lines || []).filter(Boolean);
list.innerHTML = rows.length
? rows.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
}
if (sumEl) {
if (pollRefresh) fillDigestInstant(sumEl, digest);
else typewriterLiveDigest(sumEl, digest, twOpts);
function renderCeoDigest(digest) {
if (!digest) return;
var b2b = digest.b2b, b2c = digest.b2c, focus = digest.focus, trends = digest.trends, news = digest.news;
if (!b2b || !b2c) {
(digest.sections || []).forEach(function (s) {
if (s.brand === 'foodlinkk') b2b = s.lines;
if (s.brand === 'cucina') b2c = s.lines;
if (s.brand === 'platform' || (s.title || '').indexOf('Focus') >= 0) focus = s.lines;
});
}
fillBrandCard(document.getElementById('ceo-b2b'), b2b);
fillBrandCard(document.getElementById('ceo-b2c'), b2c);
fillSimpleList(document.getElementById('ceo-focus-list'), focus || digest.actions, 'Geen urgente acties');
fillSimpleList(document.getElementById('ceo-trends-list'), trends, 'Geen trends');
fillSimpleList(document.getElementById('ceo-news-list'), news, 'Geen nieuws — open RSS tab');
}
var actions = digest.actions || [];
if (actEl) {
if (pollRefresh) fillListInstant(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail']);
else typewriterListItems(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail'], { charMs: isUpdate ? 6 : 8 });
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var meta = document.getElementById('briefing-meta');
lastDigestSig = digestSignature(digest);
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
meta.textContent = 'Live · ververst ' + ts + ' UTC';
}
var lt = digest.long_term || [];
if (longEl) {
if (pollRefresh) fillListInstant(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen']);
else setTimeout(function () {
typewriterListItems(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen'], { charMs: isUpdate ? 6 : 8 });
}, isUpdate ? 200 : 400);
if (document.querySelector('.hm-ceo-pro')) {
renderCeoDigest(digest);
return;
}
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl) fillDigestInstant(sumEl, digest);
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
@@ -597,24 +645,19 @@ window.BriefingCharts = (function () {
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), stats);
var actEl = document.getElementById('briefing-activity-log');
if (actEl) {
var log = stats.activity_log || [];
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
}
// activity log removed from CEO overview
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
// CEO overview: geen sentiment/words/agents-charts (lege hoogte + troep)
if (!document.querySelector('.hm-ceo-clean')) {
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
}
+4
View File
@@ -30,6 +30,10 @@ window.Cockpit = (function () {
const fetchOpts = Object.assign({}, options, { headers: headers });
if (options.signal) fetchOpts.signal = options.signal;
const res = await fetch(url, fetchOpts);
if (res.status === 401 && !String(url).includes('/api/auth/login')) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
throw new Error('Niet ingelogd');
}
let data = null;
try { data = await res.json(); } catch (e) { data = null; }
if (!res.ok) {
+38 -8
View File
@@ -2,8 +2,8 @@
var WIDGET_META = {
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
retail: { title: 'Retail operatie', icon: '🏪' },
briefing: { title: 'Focus & trends', icon: '🎯' },
news: { title: 'Laatste nieuws', icon: '📰' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
feed: { title: 'Agent feed', icon: '⚡' },
@@ -26,10 +26,16 @@
nas_docs: { label: 'NAS documenten', sub: 'geanalyseerd', icon: '📊', color: '#818cf8', link: '/documents' },
promo_campaigns: { label: 'Promo campagnes', sub: 'actief', icon: '📁', color: '#c084fc', link: '/marketing?tab=reclame' },
market_best: { label: 'Aandeel top', sub: 'retail markt', icon: '📈', color: '#34d399', link: '/analytics' },
margin_month: { label: 'Marge / mnd', sub: 'Revenue Cockpit', icon: '💶', color: '#fbbf24', link: '/revenue-cockpit' },
margin_year: { label: 'Marge / jr', sub: 'Revenue Cockpit', icon: '📅', color: '#38bdf8', link: '/revenue-cockpit' },
open_steps: { label: 'Open stappen', sub: 'opvolging nodig', icon: '🎯', color: '#f472b6', link: '/revenue-cockpit' },
revenue_deals: { label: 'Actieve deals', sub: 'met marge', icon: '💼', color: '#4ade80', link: '/revenue-cockpit' },
};
var DEFAULT_LAYOUT = Object.keys(WIDGET_META);
var DEFAULT_KPIS = ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
var DEFAULT_LAYOUT = ['kpis', 'executive', 'analytics', 'briefing', 'news', 'approvals', 'feed'];
var CEO_LAYOUT = ['kpis', 'executive', 'analytics', 'briefing', 'news'];
var DEFAULT_KPIS = ['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
var CEO_KPIS = ['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
function loadPrefs() {
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
@@ -73,21 +79,42 @@
return savePrefs(patch);
}
function ceoKpis(prefs) {
var k = (prefs && prefs.dashboard_kpis) || [];
if (!k.length || k.indexOf('margin_month') < 0) return CEO_KPIS.slice();
// ensure core revenue KPIs present without dropping user extras
var need = ['margin_month', 'open_steps', 'revenue_deals'];
need.forEach(function (id) {
if (k.indexOf(id) < 0) k = [id].concat(k);
});
return k.slice(0, 6);
}
function visibleLayout(prefs) {
var layout = prefs.dashboard_layout || DEFAULT_LAYOUT.slice();
var widgets = prefs.dashboard_widgets || defaultWidgets();
return layout.filter(function (id) { return widgets[id] !== false; });
var rest = (prefs.dashboard_layout || DEFAULT_LAYOUT.slice()).filter(function (id) {
return CEO_LAYOUT.indexOf(id) < 0 && widgets[id] !== false;
});
var top = CEO_LAYOUT.filter(function (id) { return widgets[id] !== false; });
return top.concat(rest);
}
function applyOrder(container, order) {
if (!container) return;
var map = {};
var leftovers = [];
Array.from(container.children).forEach(function (el) {
if (el.dataset && el.dataset.widget) map[el.dataset.widget] = el;
else leftovers.push(el);
});
order.forEach(function (id) {
if (map[id]) container.appendChild(map[id]);
var seen = {};
(order || []).forEach(function (id) {
if (map[id]) { container.appendChild(map[id]); seen[id] = true; }
});
Object.keys(map).forEach(function (id) {
if (!seen[id]) container.appendChild(map[id]);
});
leftovers.forEach(function (el) { container.appendChild(el); });
}
function applyVisibility(container, widgets) {
@@ -257,12 +284,15 @@
WIDGET_META: WIDGET_META,
KPI_META: KPI_META,
DEFAULT_KPIS: DEFAULT_KPIS,
CEO_KPIS: CEO_KPIS,
ceoKpis: ceoKpis,
loadPrefs: loadPrefs,
saveLayout: saveLayout,
saveViz: saveViz,
savePrefs: savePrefs,
saveDashboardConfig: saveDashboardConfig,
visibleLayout: visibleLayout,
CEO_LAYOUT: CEO_LAYOUT,
applyOrder: applyOrder,
applyVisibility: applyVisibility,
initDrag: initDrag,
+302 -91
View File
@@ -1,13 +1,7 @@
/**
* Export Intel map — Leaflet + clustering + rich popups
* Export Intel map — Leaflet + clustering (multi-instance per tab)
*/
window.ExportIntelMap = (function () {
var map = null;
var clusterLayer = null;
var onEntityClick = null;
var halalMode = false;
var markersById = {};
var HALAL_COLORS = {
hot: '#22c55e',
warm: '#eab308',
@@ -27,6 +21,9 @@ window.ExportIntelMap = (function () {
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
};
var instances = {};
var DEFAULT_ID = 'ei-map';
function cfg(type) {
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
}
@@ -52,19 +49,128 @@ window.ExportIntelMap = (function () {
);
}
function normalizeUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u) return null;
if (/^mailto:/i.test(u) || /^tel:/i.test(u)) return u;
if (!/^https?:\/\//i.test(u)) u = 'https://' + u.replace(/^\/+/, '');
return u;
}
function channelList(items, cls) {
if (!items || !items.length) return '';
return items.map(function (it) {
var val = escapeHtml(it.value || it.display || '');
var label = escapeHtml(it.label || '');
var href = it.href || it.url;
if (href) {
return '<a class="ei-popup-link ' + (cls || '') + '" href="' + escapeHtml(href) + '" target="_blank" rel="noopener" title="' + val + '">' +
(label ? '<span class="ei-popup-link-label">' + label + '</span>' : '') +
'<span class="ei-popup-link-val">' + val + '</span></a>';
}
return '<div class="ei-popup-plain">' + (label ? '<span class="ei-popup-link-label">' + label + '</span>' : '') +
'<span class="ei-popup-link-val">' + val + '</span></div>';
}).join('');
}
function contactPeopleBlock(people) {
if (!people || !people.length) return '';
var cards = people.map(function (c) {
var head = escapeHtml(c.name || c.role || 'Contact');
var sub = escapeHtml([c.role, c.title, c.department].filter(Boolean).join(' · '));
var bits = [];
if (c.email) bits.push('<a class="ei-popup-link" href="mailto:' + escapeHtml(c.email) + '">📧 ' + escapeHtml(c.email) + '</a>');
if (c.email_alt) bits.push('<a class="ei-popup-link" href="mailto:' + escapeHtml(c.email_alt) + '">📧 ' + escapeHtml(c.email_alt) + ' (alt)</a>');
if (c.phone) bits.push('<a class="ei-popup-link" href="tel:' + escapeHtml(c.phone) + '">📞 ' + escapeHtml(c.phone) + '</a>');
if (c.mobile) bits.push('<a class="ei-popup-link" href="tel:' + escapeHtml(c.mobile) + '">📱 ' + escapeHtml(c.mobile) + '</a>');
if (c.whatsapp) bits.push('<a class="ei-popup-link" href="https://wa.me/' + escapeHtml(String(c.whatsapp).replace(/\D/g, '')) + '" target="_blank" rel="noopener">💬 ' + escapeHtml(c.whatsapp) + '</a>');
if (c.website) bits.push('<a class="ei-popup-link" href="' + escapeHtml(c.website) + '" target="_blank" rel="noopener">🌐 Website</a>');
if (c.linkedin_url) bits.push('<a class="ei-popup-link" href="' + escapeHtml(c.linkedin_url) + '" target="_blank" rel="noopener">in LinkedIn</a>');
return '<div class="ei-popup-person">' +
'<div class="ei-popup-person-head">' + head + (c.is_primary ? ' <span class="ei-popup-primary">primair</span>' : '') + '</div>' +
(sub ? '<div class="ei-popup-person-sub">' + sub + '</div>' : '') +
(bits.length ? '<div class="ei-popup-person-links">' + bits.join('') + '</div>' : '') +
'</div>';
}).join('');
return '<div class="ei-popup-section"><div class="ei-popup-section-title">Contactpersonen</div>' + cards + '</div>';
}
function reachBlock(p) {
var r = p.reach || {};
if (!r.has_any && !p.email && !p.phone && !(p.websites && p.websites.length)) {
return '<div class="ei-popup-empty-contact">Nog geen contact — sync contacten of vul handmatig aan.</div>';
}
var html = '<div class="ei-popup-reach">';
if ((r.emails && r.emails.length) || p.email) {
var emails = r.emails && r.emails.length ? r.emails : (p.email ? [{ label: 'E-mail', value: p.email, href: 'mailto:' + p.email }] : []);
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">E-mail</div><div class="ei-popup-channel-list">' + channelList(emails) + '</div></div>';
}
if ((r.phones && r.phones.length) || p.phone) {
var phones = r.phones && r.phones.length ? r.phones : (p.phone ? [{ label: 'Telefoon', value: p.phone, href: 'tel:' + p.phone }] : []);
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Telefoon</div><div class="ei-popup-channel-list">' + channelList(phones) + '</div></div>';
}
var webHtml = websitesBlock(p);
if (webHtml) html += webHtml.replace('ei-popup-row ei-popup-websites', 'ei-popup-section ei-popup-websites').replace('ei-popup-k">Websites', 'ei-popup-section-title">Websites').replace('ei-popup-v ei-popup-web-list', 'ei-popup-web-list');
if (r.addresses && r.addresses.length) {
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Adres</div><div class="ei-popup-channel-list">' + channelList(r.addresses) + '</div></div>';
}
if (r.social && r.social.length) {
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Social</div><div class="ei-popup-channel-list">' + channelList(r.social) + '</div></div>';
}
if (r.maps_url) {
html += '<div class="ei-popup-section"><a class="ei-popup-maps" href="' + escapeHtml(r.maps_url) + '" target="_blank" rel="noopener">📍 Open in Google Maps</a></div>';
}
html += contactPeopleBlock(r.contact_people);
html += '</div>';
return html;
}
function websitesBlock(p) {
var sites = p.websites;
if ((!sites || !sites.length) && p.website) {
var w = normalizeUrl(p.website);
if (w) sites = [{ label: 'Organisatie', url: w, display: p.website }];
}
if (!sites || !sites.length) return '';
var links = sites.map(function (s) {
var href = normalizeUrl(s.url || s);
if (!href) return '';
var label = escapeHtml(s.label || 'Website');
var disp = escapeHtml(s.display || s.url || '');
return '<a class="ei-popup-web" href="' + escapeHtml(href) + '" target="_blank" rel="noopener" title="' + disp + '">' + label + '</a>';
}).filter(Boolean).join('');
if (!links) return '';
return (
'<div class="ei-popup-row ei-popup-websites">' +
'<span class="ei-popup-k">Websites</span>' +
'<span class="ei-popup-v ei-popup-web-list">' + links + '</span>' +
'</div>'
);
}
function halalColor(p) {
var tier = p.halal_tier || 'low';
return HALAL_COLORS[tier] || HALAL_COLORS.low;
}
function popupHtml(p) {
function MapInstance(containerId) {
this.containerId = containerId;
this.map = null;
this.clusterLayer = null;
this.onEntityClick = null;
this.halalMode = false;
this.markersById = {};
}
MapInstance.prototype.popupHtml = function (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 || '') : ''));
halalRow = row('Halal kans', p.halal_score + '/100' + (this.halalMode ? ' · ' + (p.halal_tier || '') : ''));
}
var contactBits = [];
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
@@ -75,7 +181,7 @@ window.ExportIntelMap = (function () {
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-badge" style="--badge-color:' + (this.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>' +
@@ -86,7 +192,7 @@ window.ExportIntelMap = (function () {
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) +
websitesBlock(p) +
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) +
@@ -97,12 +203,12 @@ window.ExportIntelMap = (function () {
'</div>' +
'</div>'
);
}
};
function pinIcon(type, p) {
MapInstance.prototype.pinIcon = function (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' : '';
var color = this.halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
var ring = this.halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
return L.divIcon({
className: 'ei-pin-wrap',
html:
@@ -114,22 +220,58 @@ window.ExportIntelMap = (function () {
iconAnchor: [15, 15],
popupAnchor: [0, -16],
});
}
};
function openEntityId(rawId) {
MapInstance.prototype.openEntityId = function (rawId) {
var id = Number(rawId);
if (!Number.isFinite(id)) return;
if (onEntityClick) {
onEntityClick(id);
if (this.onEntityClick) {
this.onEntityClick(id);
return;
}
try {
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
} catch (e) { /* empty */ }
}
};
function wirePopup(container) {
MapInstance.prototype.enrichPopup = function (entityId, popup) {
if (!entityId || !popup) return;
var self = this;
fetch('/api/export-intel/entities/' + entityId)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (ent) {
if (!ent || !popup || !popup.isOpen || !popup.isOpen()) return;
var merged = {
id: ent.id,
name: ent.name,
entity_type: ent.entity_type,
country_iso2: ent.country_iso2,
city: ent.city,
address_line: ent.address_line,
postal_code: ent.postal_code,
email: ent.email,
phone: ent.phone,
website: ent.website,
websites: ent.websites || [],
reach: ent.reach || null,
volume_band: ent.volume_band,
confidence: ent.confidence,
pipeline_stage: ent.pipeline_stage,
contact_count: ent.contact_count,
halal_score: ent.halal_score,
halal_tier: ent.halal_tier,
};
popup.setContent(self.popupHtml(merged));
var el = popup.getElement();
if (el) self.wirePopup(el.querySelector('.leaflet-popup-content') || el);
})
.catch(function () { /* keep cached popup */ });
};
MapInstance.prototype.wirePopup = function (container) {
if (!container) return;
var self = this;
var popupRoot = container.closest('.leaflet-popup') || container;
if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(popupRoot);
@@ -143,7 +285,7 @@ window.ExportIntelMap = (function () {
e.preventDefault();
e.stopPropagation();
}
openEntityId(id);
self.openEntityId(id);
};
if (window.L && L.DomEvent) {
L.DomEvent.on(btn, 'click', onBtn);
@@ -156,38 +298,39 @@ window.ExportIntelMap = (function () {
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'));
if (card) self.openEntityId(card.getAttribute('data-entity-id'));
});
}
};
function invalidateMapSize() {
if (!map) return;
MapInstance.prototype.invalidateSize = function () {
if (!this.map) return;
var map = this.map;
map.invalidateSize(true);
setTimeout(function () { if (map) map.invalidateSize(true); }, 120);
setTimeout(function () { if (map) map.invalidateSize(true); }, 400);
setTimeout(function () { if (map) map.invalidateSize(true); }, 800);
}
};
function init(containerId) {
var el = document.getElementById(containerId);
MapInstance.prototype.init = function () {
var el = document.getElementById(this.containerId);
if (!el || !window.L) return null;
if (map && map.getContainer() === el) {
invalidateMapSize();
return map;
if (this.map && this.map.getContainer() === el) {
this.invalidateSize();
return this.map;
}
if (map) {
map.remove();
map = null;
clusterLayer = null;
if (this.map) {
this.map.remove();
this.map = null;
this.clusterLayer = null;
}
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
var self = this;
this.map = L.map(this.containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
L.tileLayer('/api/map/tiles/{z}/{x}/{y}.png', {
attribution: '&copy; OSM &copy; CARTO',
maxZoom: 19,
}).addTo(map);
}).addTo(this.map);
if (window.L.markerClusterGroup) {
clusterLayer = L.markerClusterGroup({
this.clusterLayer = L.markerClusterGroup({
maxClusterRadius: 42,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
@@ -202,100 +345,168 @@ window.ExportIntelMap = (function () {
});
},
});
map.addLayer(clusterLayer);
this.map.addLayer(this.clusterLayer);
} else {
clusterLayer = L.layerGroup();
map.addLayer(clusterLayer);
this.clusterLayer = L.layerGroup();
this.map.addLayer(this.clusterLayer);
}
map.on('popupopen', function (e) {
this.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);
self.wirePopup(root.querySelector('.leaflet-popup-content') || root);
var card = root.querySelector('[data-entity-id]');
var eid = card && card.getAttribute('data-entity-id');
if (eid) self.enrichPopup(Number(eid), e.popup);
});
invalidateMapSize();
return map;
}
this.invalidateSize();
return this.map;
};
function setFeatures(geojson, opts) {
MapInstance.prototype.setFeatures = function (geojson, opts) {
opts = opts || {};
if (!map || !clusterLayer) return;
clusterLayer.clearLayers();
markersById = {};
if (!this.map || !this.clusterLayer) return;
this.clusterLayer.clearLayers();
this.markersById = {};
var features = (geojson && geojson.features) || [];
var markers = [];
var self = this;
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) });
var marker = L.marker([coords[1], coords[0]], { icon: self.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);
if (p.id != null) self.markersById[p.id] = marker;
marker.bindPopup(self.popupHtml(p), { maxWidth: 360, minWidth: 280, maxHeight: 420, className: 'ei-leaflet-popup', autoClose: false, closeOnClick: false });
marker.on('click', function (e) {
if (window.L && L.DomEvent) L.DomEvent.stopPropagation(e);
marker.openPopup();
if (self.onEntityClick && p.id != null) self.onEntityClick(p.id);
});
markers.push(marker);
});
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
if (this.clusterLayer.addLayers) this.clusterLayer.addLayers(markers);
else markers.forEach(function (m) { self.clusterLayer.addLayer(m); });
invalidateMapSize();
this.invalidateSize();
if (opts.autoFit === false) {
map.setView([20, 10], 2);
this.map.setView([20, 10], 2);
} else if (features.length === 1) {
var c = features[0].geometry.coordinates;
map.setView([c[1], c[0]], 10);
} else if (opts.autoFit && markers.length > 1 && clusterLayer.getBounds) {
this.map.setView([c[1], c[0]], 10);
} else if (opts.autoFit && markers.length > 1 && this.clusterLayer.getBounds) {
try {
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
this.map.fitBounds(this.clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
} catch (e) { /* empty */ }
}
}
};
function setHalalMode(on) {
halalMode = !!on;
}
MapInstance.prototype.setHalalMode = function (on) {
this.halalMode = !!on;
};
function setOnEntityClick(fn) {
onEntityClick = fn;
}
MapInstance.prototype.setOnEntityClick = function (fn) {
this.onEntityClick = typeof fn === 'function' ? fn : null;
};
function flyTo(lat, lon, zoom) {
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
}
MapInstance.prototype.flyTo = function (lat, lon, zoom) {
if (this.map && lat && lon) this.map.setView([lat, lon], zoom || 10);
};
function highlightEntity(id) {
var marker = markersById[id];
if (!marker || !map) return;
MapInstance.prototype.highlightEntity = function (id) {
var marker = this.markersById[id];
if (!marker || !this.map) return;
var latlng = marker.getLatLng();
map.setView(latlng, Math.max(map.getZoom(), 14));
this.map.setView(latlng, Math.max(this.map.getZoom(), 14));
marker.openPopup();
}
};
function closePopup() {
if (map) map.closePopup();
}
MapInstance.prototype.closePopup = function () {
if (this.map) this.map.closePopup();
};
function destroy() {
if (map) {
map.remove();
map = null;
clusterLayer = null;
MapInstance.prototype.destroy = function () {
if (this.map) {
this.map.remove();
this.map = null;
this.clusterLayer = null;
}
delete instances[this.containerId];
};
function resolveId(containerId) {
return containerId || DEFAULT_ID;
}
function getOrCreate(containerId) {
var id = resolveId(containerId);
if (!instances[id]) instances[id] = new MapInstance(id);
return instances[id];
}
function init(containerId) {
return getOrCreate(containerId).init();
}
function setFeatures(geojson, opts, containerId) {
getOrCreate(containerId).setFeatures(geojson, opts);
}
function setHalalMode(on, containerId) {
getOrCreate(containerId).setHalalMode(on);
}
function setOnEntityClick(fn, containerId) {
getOrCreate(containerId).setOnEntityClick(fn);
}
function flyTo(lat, lon, zoom, containerId) {
getOrCreate(containerId).flyTo(lat, lon, zoom);
}
function highlightEntity(id, containerId) {
getOrCreate(containerId).highlightEntity(id);
}
function closePopup(containerId) {
getOrCreate(containerId).closePopup();
}
function destroy(containerId) {
if (containerId) getOrCreate(containerId).destroy();
else {
Object.keys(instances).forEach(function (k) { instances[k].destroy(); });
}
}
function invalidateAll() {
Object.keys(instances).forEach(function (k) { instances[k].invalidateSize(); });
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', function () { invalidateMapSize(); });
window.addEventListener('resize', invalidateAll);
}
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
return {
init: init,
setFeatures: setFeatures,
setOnEntityClick: setOnEntityClick,
setHalalMode: setHalalMode,
flyTo: flyTo,
closePopup: closePopup,
highlightEntity: highlightEntity,
destroy: destroy,
invalidateAll: invalidateAll,
TYPE_CFG: TYPE_CFG,
cfg: cfg,
};
})();
+591
View File
@@ -0,0 +1,591 @@
/**
* Export Intel — profiel popup (imperatief, geen Alpine in modal).
*/
(function (global) {
'use strict';
function normalizeWebUrl(u) {
if (!u) return '';
u = String(u).trim();
if (!u) return '';
if (!/^https?:\/\//i.test(u)) u = 'https://' + u.replace(/^\/+/, '');
return u;
}
function contactGapHint(e) {
var r = e.reach || {};
var hasDirect = !!(e.phone || e.email || e.website || (r.phones && r.phones.length) || (r.emails && r.emails.length) || (r.websites && r.websites.length));
if (hasDirect) return '';
var src = e.source || 'osm';
return '<p class="ei-drawer-empty ei-drawer-hint">Geen telefoon of e-mail in bron (' + esc(src) + '). Gratis opties: <strong>Bewerken</strong> (handmatig), of <strong>Sync contacten</strong> (scant websites) / <strong>Sync distributeurs</strong> (haalt OSM-tags opnieuw).</p>';
}
function reachSectionHtml(e) {
var r = e.reach || {};
var html = '';
function links(title, items, render) {
if (!items || !items.length) return;
html += '<div class="ei-reach-block"><h5>' + esc(title) + '</h5>';
for (var i = 0; i < items.length; i++) html += render(items[i]);
html += '</div>';
}
links('E-mail', r.emails, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'E-mail') + '</span><a href="' + esc(it.href || ('mailto:' + it.value)) + '">' + esc(it.value) + '</a></div>';
});
links('Telefoon', r.phones, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Tel') + '</span><a href="' + esc(it.href || ('tel:' + it.value)) + '">' + esc(it.value) + '</a></div>';
});
if (!r.emails || !r.emails.length) {
if (e.email) html += '<div class="ei-drawer-kv"><span>E-mail</span><a href="mailto:' + esc(e.email) + '">' + esc(e.email) + '</a></div>';
}
if (!r.phones || !r.phones.length) {
if (e.phone) html += '<div class="ei-drawer-kv"><span>Telefoon</span><a href="tel:' + esc(e.phone) + '">' + esc(e.phone) + '</a></div>';
}
html += websitesHtml(e);
links('Adres', r.addresses, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Adres') + '</span><span>' + esc(it.value) + '</span></div>';
});
links('Social', r.social, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Link') + '</span><a href="' + esc(it.href) + '" target="_blank" rel="noopener">' + esc(it.value) + '</a></div>';
});
if (r.maps_url) {
html += '<div class="ei-drawer-kv"><span>Maps</span><a href="' + esc(r.maps_url) + '" target="_blank" rel="noopener">Google Maps →</a></div>';
}
return html;
}
function websitesHtml(e) {
var sites = e.websites;
if ((!sites || !sites.length) && e.website) {
sites = [{ label: 'Organisatie', url: e.website, display: e.website }];
}
if (!sites || !sites.length) return '';
var rows = '';
for (var i = 0; i < sites.length; i++) {
var s = sites[i];
var href = normalizeWebUrl(s.url || s);
if (!href) continue;
rows += '<div class="ei-drawer-kv"><span>' + esc(s.label || 'Website') + '</span>' +
'<a href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(s.display || s.url || href) + '</a></div>';
}
return rows;
}
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function typeLabel(t) {
return (t || '').replace(/_/g, ' ');
}
function getApp() {
return global.__exportIntelApp || null;
}
function territoryName(iso2) {
var app = getApp();
if (!iso2) return '';
if (app && app.territories) {
for (var i = 0; i < app.territories.length; i++) {
if (app.territories[i].country_iso2 === iso2) return app.territories[i].name_nl;
}
}
return iso2;
}
function formatCoords(lat, lon) {
if (lat == null || lon == null) return '';
var a = Number(lat);
var b = Number(lon);
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return a.toFixed(5) + ', ' + b.toFixed(5);
}
function mountEl() {
return document.getElementById('ei-profile-mount');
}
function activeMapId(app) {
if (!app) return 'ei-map';
if (app.tab === 'map') return 'ei-map';
if (app.tabMapId && typeof app.tabMapId === 'function') {
return app.tabMapId() || 'ei-map';
}
return 'ei-map-' + (app.tab || 'map');
}
var Profile = {
_busy: false,
showShell: function (on) {
var el = document.getElementById('ei-profile-modal');
if (!el) return;
if (on) {
el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false');
document.body.classList.add('ei-profile-open');
} else {
el.classList.remove('is-open');
el.setAttribute('aria-hidden', 'true');
document.body.classList.remove('ei-profile-open');
}
},
clearMount: function () {
var m = mountEl();
if (m) m.innerHTML = '';
},
renderLoading: function () {
var m = mountEl();
if (!m) return;
m.innerHTML =
'<div class="ei-drawer-loading" style="padding:3rem 1.5rem;text-align:center">' +
'<span class="ei-map-spinner"></span>' +
'<p style="margin-top:1rem;color:#94a3b8">Profiel laden…</p></div>';
},
renderError: function (msg) {
var m = mountEl();
if (!m) return;
m.innerHTML =
'<div class="ei-drawer-loading" style="padding:2rem 1.5rem">' +
'<p style="color:#fecdd3;margin-bottom:1rem">' + esc(msg || 'Laden mislukt') + '</p>' +
'<button type="button" class="btn btn-sm" data-ei-action="close">Sluiten</button></div>';
},
patchEntityInApp: function (app, entity) {
if (!app || !entity || !entity.id) return;
var id = entity.id;
if (Array.isArray(app.entities)) {
for (var i = 0; i < app.entities.length; i++) {
if (app.entities[i].id === id) {
Object.assign(app.entities[i], entity);
}
}
}
if (Array.isArray(app.crmPipeline)) {
for (var j = 0; j < app.crmPipeline.length; j++) {
if (app.crmPipeline[j].id === id) {
Object.assign(app.crmPipeline[j], entity);
}
}
}
if (Array.isArray(app.halalTopEntities)) {
for (var k = 0; k < app.halalTopEntities.length; k++) {
if (app.halalTopEntities[k].id === id) {
Object.assign(app.halalTopEntities[k], entity);
}
}
}
},
renderFull: function (e, app) {
var m = mountEl();
if (!m || !e) return;
app = app || getApp();
var contacts = e.contacts || [];
var contactRows = '';
for (var i = 0; i < contacts.length; i++) {
var c = contacts[i];
contactRows +=
'<div class="ei-contact-row">' +
'<strong>' + esc(c.name || c.role || 'Contact') + '</strong>' +
(c.role ? '<span class="ei-contact-role">' + esc(c.role) + '</span>' : '') +
(c.email ? '<div>📧 <a href="mailto:' + esc(c.email) + '">' + esc(c.email) + '</a></div>' : '') +
(c.phone ? '<div>📞 <a href="tel:' + esc(c.phone) + '">' + esc(c.phone) + '</a></div>' : '') +
(c.website_contact_url ? '<div>🌐 <a href="' + esc(normalizeWebUrl(c.website_contact_url)) + '" target="_blank" rel="noopener">' + esc(c.website_contact_url) + '</a></div>' : '') +
(c.linkedin_url ? '<div>in <a href="' + esc(normalizeWebUrl(c.linkedin_url)) + '" target="_blank" rel="noopener">LinkedIn</a></div>' : '') +
'</div>';
}
var halalTags = '';
var labels = e.halal_reason_labels || [];
for (var j = 0; j < labels.length; j++) {
halalTags += '<span class="ei-halal-tag">' + esc(labels[j]) + '</span>';
}
var crmBtn = e.client_id
? '<a class="ei-action-tile ei-action-crm-done" href="/clients"><span class="ei-action-icon">✓</span><span class="ei-action-label">Open CRM</span><span class="ei-action-sub">' +
esc(e.crm_client_name || ('Klant #' + e.client_id)) + '</span></a>'
: '<button type="button" class="ei-action-tile ei-action-crm" data-ei-action="crm" data-ei-id="' + e.id + '"><span class="ei-action-icon">➕</span><span class="ei-action-label">Naar CRM</span><span class="ei-action-sub">Klant + deal</span></button>';
var mapsBtn = e.maps_url
? '<a class="ei-action-tile" href="' + esc(e.maps_url) + '" target="_blank" rel="noopener"><span class="ei-action-icon">📍</span><span class="ei-action-label">Maps</span><span class="ei-action-sub">Locatie</span></a>'
: '';
var dealBtn = e.deal_id
? '<a class="ei-action-tile" href="/deals"><span class="ei-action-icon">💼</span><span class="ei-action-label">Deal</span><span class="ei-action-sub">Pipeline</span></a>'
: '';
var favOn = !!e.is_favorite;
m.innerHTML =
'<header class="ei-drawer-head-rich">' +
'<div class="ei-drawer-head-top">' +
'<div class="ei-drawer-hero">' +
'<span class="ei-drawer-badge ei-type-' + esc(e.entity_type) + '">' + esc(typeLabel(e.entity_type)) + '</span>' +
'<span class="ei-drawer-flag">' + esc(e.country_iso2) + '</span>' +
(e.client_id ? '<span class="ei-crm-badge">In CRM</span>' : '') +
(favOn ? '<span class="ei-crm-badge" style="color:#facc15;border-color:rgba(251,191,36,.4)">★ Favoriet</span>' : '') +
(e.halal_score != null ? '<span class="ei-halal-pill ei-halal-hot">' + Math.round(Number(e.halal_score)) + '/100</span>' : '') +
'</div>' +
'<button type="button" class="ei-drawer-close" data-ei-action="close" title="Sluiten">✕</button>' +
'</div>' +
'<h2 class="ei-drawer-title" id="ei-profile-title">' + esc(e.name) + '</h2>' +
'<p class="ei-drawer-meta">' + esc(territoryName(e.country_iso2)) +
' · Confidence <strong>' + (e.confidence || 0) + '%</strong>' +
' · Pipeline <strong>' + esc(e.pipeline_stage || '—') + '</strong>' +
' · Bron <strong>' + esc(e.source || '—') + '</strong></p>' +
'</header>' +
'<div class="ei-drawer-toolbar">' +
crmBtn +
'<button type="button" class="ei-action-tile ei-action-edit" data-ei-action="edit"><span class="ei-action-icon">✎</span><span class="ei-action-label">Bewerken</span><span class="ei-action-sub">Velden</span></button>' +
'<button type="button" class="ei-action-tile' + (favOn ? ' ei-action-fav-on' : '') + '" data-ei-action="favorite" data-ei-id="' + e.id + '" data-ei-fav="' + (favOn ? '1' : '0') + '"><span class="ei-action-icon">' + (favOn ? '★' : '☆') + '</span><span class="ei-action-label">' + (favOn ? 'Favoriet' : 'Favoriet maken') + '</span></button>' +
mapsBtn +
'<button type="button" class="ei-action-tile" data-ei-action="sync-contacts"><span class="ei-action-icon">↻</span><span class="ei-action-label">Contacten</span></button>' +
dealBtn +
'<a class="ei-action-tile" href="/herman?context=export_catalog&entity=' + e.id + '"><span class="ei-action-icon">📋</span><span class="ei-action-label">Herman</span></a>' +
'</div>' +
'<div class="ei-drawer-body ei-drawer-body-rich">' +
(halalTags ? '<div class="ei-halal-tags">' + halalTags + '</div>' : '') +
((e.city || e.address_line || (e.lat && e.lon))
? '<section class="ei-drawer-section"><h4>Locatie</h4>' +
(e.address_line ? '<p>' + esc(e.address_line) + '</p>' : '') +
(e.city ? '<p>' + esc(e.city) + (e.postal_code ? ', ' + esc(e.postal_code) : '') + '</p>' : '') +
((e.lat && e.lon)
? '<p class="ei-drawer-coords">' + esc(formatCoords(e.lat, e.lon)) +
(e.maps_url ? ' <a href="' + esc(e.maps_url) + '" target="_blank" rel="noopener">Maps →</a>' : '') + '</p>'
: '') +
'</section>'
: '') +
'<section class="ei-drawer-section"><h4>Contact & web</h4>' +
reachSectionHtml(e) + contactGapHint(e) +
((!e.reach || !e.reach.has_any) && !e.email && !e.phone && !(e.websites && e.websites.length) && !e.website && !contacts.length ? '<p class="ei-drawer-empty">Geen contact — sync contacten om te verrijken.</p>' : '') +
'</section>' +
(e.client_id
? '<section class="ei-drawer-section"><h4>CRM</h4>' +
'<div class="ei-drawer-kv"><span>Klant</span><a href="/clients">' + esc(e.crm_client_name || ('#' + e.client_id)) + '</a></div>' +
(e.crm_deal_title ? '<div class="ei-drawer-kv"><span>Deal</span><span>' + esc(e.crm_deal_title) + '</span></div>' : '') +
'</section>'
: '') +
'<section class="ei-drawer-section"><h4>Contactpersonen (' + contacts.length + ')</h4>' +
(contactRows || '<p class="ei-drawer-empty">Geen contactpersonen.</p>') +
'</section></div>';
if (app) {
app.selected = e;
app.drawerOpen = true;
app.drawerLoading = false;
app.listFocusDetail = e;
app.listFocusId = e.id;
}
},
fetchEntity: function (id) {
return fetch('/api/export-intel/entities/' + id).then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error(d.error || d.detail || ('HTTP ' + r.status));
if (d && d.error) throw new Error(d.error);
return d;
});
});
},
highlightOnMap: function (entity, app) {
if (!entity || !entity.lat || !entity.lon || !global.ExportIntelMap) return;
try {
var mapId = activeMapId(app);
global.ExportIntelMap.flyTo(entity.lat, entity.lon, 14, mapId);
global.ExportIntelMap.highlightEntity(entity.id, mapId);
} catch (e) { /* ignore */ }
},
open: function (id) {
id = Number(id);
if (!Number.isFinite(id) || id <= 0) return;
if (this._busy) return;
this._busy = true;
this.showShell(true);
this.renderLoading();
var self = this;
var app = getApp();
this.fetchEntity(id)
.then(function (entity) {
self.renderFull(entity, app);
self.highlightOnMap(entity, app);
})
.catch(function (err) {
self.renderError(err.message || 'Profiel laden mislukt');
})
.finally(function () {
self._busy = false;
});
},
close: function () {
this._busy = false;
this.showShell(false);
this.clearMount();
var app = getApp();
if (app) {
app.drawerOpen = false;
app.drawerLoading = false;
app.selected = null;
app.editMode = false;
}
},
setFavorite: function (id, currentlyFavorite) {
var self = this;
var app = getApp();
id = Number(id);
if (!Number.isFinite(id)) return Promise.reject(new Error('Ongeldig id'));
return fetch('/api/export-intel/entities/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_ids: [id], favorite: !currentlyFavorite }),
})
.then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error((d && (d.error || d.detail)) || 'Favoriet mislukt');
return d;
});
})
.then(function () {
return self.fetchEntity(id);
})
.then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
app.listFocusId = entity.id;
app.syncMsg = entity.is_favorite ? '★ Favoriet opgeslagen' : 'Favoriet verwijderd';
if (app.stats && typeof app.stats.favorites === 'number') {
app.stats.favorites += entity.is_favorite ? 1 : -1;
if (app.stats.favorites < 0) app.stats.favorites = 0;
}
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
return entity;
});
},
doCrm: function (id) {
var self = this;
var app = getApp();
id = Number(id);
if (!app || !app.pushEntityToCrm) return;
app.pushEntityToCrm(id).then(function () {
return self.fetchEntity(id);
}).then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
}).catch(function (err) {
if (app) app.syncMsg = 'CRM mislukt: ' + (err.message || err);
});
},
doEdit: function () {
var app = getApp();
if (!app || !app.selected) return;
app.startEdit();
this.renderEdit(app);
},
renderEdit: function (app) {
var e = app.selected;
var f = app.editForm || {};
var m = mountEl();
if (!m || !e) return;
m.innerHTML =
'<header class="ei-drawer-head-rich"><div class="ei-drawer-head-top">' +
'<h2 class="ei-drawer-title">Bewerken · ' + esc(e.name) + '</h2>' +
'<button type="button" class="ei-drawer-close" data-ei-action="close">✕</button></div></header>' +
'<div class="ei-drawer-body ei-drawer-body-rich"><div class="ei-drawer-edit">' +
'<div class="ei-edit-grid">' +
'<label>Naam<input type="text" id="ei-edit-name" value="' + esc(f.name || '') + '"></label>' +
'<label>Stad<input type="text" id="ei-edit-city" value="' + esc(f.city || '') + '"></label>' +
'<label>E-mail<input type="email" id="ei-edit-email" value="' + esc(f.email || '') + '"></label>' +
'<label>Telefoon<input type="text" id="ei-edit-phone" value="' + esc(f.phone || '') + '"></label>' +
'<label>Website<input type="url" id="ei-edit-website" value="' + esc(f.website || '') + '"></label>' +
'</div><div class="ei-edit-actions">' +
'<button type="button" class="btn btn-sm btn-pulse-green" data-ei-action="save-edit">Opslaan</button>' +
'<button type="button" class="btn btn-sm" data-ei-action="cancel-edit">Annuleren</button>' +
'</div></div></div>';
},
saveEdit: function () {
var app = getApp();
if (!app || !app.selected) return;
var self = this;
app.editForm.name = (document.getElementById('ei-edit-name') || {}).value || '';
app.editForm.city = (document.getElementById('ei-edit-city') || {}).value || '';
app.editForm.email = (document.getElementById('ei-edit-email') || {}).value || '';
app.editForm.phone = (document.getElementById('ei-edit-phone') || {}).value || '';
app.editForm.website = (document.getElementById('ei-edit-website') || {}).value || '';
var id = app.selected.id;
Promise.resolve(app.saveEdit()).then(function () {
return self.fetchEntity(id);
}).then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
});
},
cancelEdit: function () {
var app = getApp();
if (!app) return;
app.cancelEdit();
if (app.selected) this.renderFull(app.selected, app);
},
doFavorite: function (id, isFav) {
var self = this;
this.setFavorite(id, isFav).catch(function (err) {
self.renderError(err.message || 'Favoriet mislukt');
});
},
doSyncContacts: function () {
var app = getApp();
if (app && app.syncKind) app.syncKind('contacts');
},
onModalClick: function (ev) {
var t = ev.target;
if (!t || !t.closest) return;
if (!t.closest('#ei-profile-modal')) return;
if (t.closest('.ei-profile-backdrop')) {
ev.preventDefault();
this.close();
return;
}
var actionEl = t.closest('[data-ei-action]');
if (!actionEl) return;
var action = actionEl.getAttribute('data-ei-action');
if (!action) return;
ev.preventDefault();
ev.stopPropagation();
var id = Number(actionEl.getAttribute('data-ei-id') || (getApp() && getApp().selected && getApp().selected.id));
if (action === 'close') this.close();
else if (action === 'crm') this.doCrm(id);
else if (action === 'edit') this.doEdit();
else if (action === 'favorite') {
var isFav = actionEl.getAttribute('data-ei-fav') === '1';
this.doFavorite(id, isFav);
}
else if (action === 'sync-contacts') this.doSyncContacts();
else if (action === 'save-edit') this.saveEdit();
else if (action === 'cancel-edit') this.cancelEdit();
},
onRowClick: function (ev) {
var t = ev.target;
if (!t || !t.closest) return;
if (t.closest('#ei-profile-modal')) return;
if (t.closest('.ei-td-check, input[type=checkbox], .ei-fav-btn, .ei-td-actions')) return;
if (t.closest('a[href]') && !t.closest('.ei-row-open-btn, .ei-link-btn')) return;
var row = t.closest('[data-entity-id]');
if (!row) return;
var id = row.getAttribute('data-entity-id');
if (!id) return;
ev.preventDefault();
ev.stopPropagation();
this.open(id);
},
bindApp: function (app) {
if (!app) return;
global.__exportIntelApp = app;
var self = this;
app.openEntity = function (id) { self.open(id); };
app.closeDrawer = function () { self.close(); };
},
wireModal: function () {
var modal = document.getElementById('ei-profile-modal');
if (!modal || modal._eiWired) return;
modal._eiWired = true;
var self = this;
modal.addEventListener('click', function (ev) { self.onModalClick(ev); }, true);
},
boot: function () {
var self = this;
this.wireModal();
document.addEventListener('click', function (ev) { self.onRowClick(ev); }, true);
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') {
var modal = document.getElementById('ei-profile-modal');
if (modal && modal.classList.contains('is-open')) self.close();
}
});
var params = new URLSearchParams(global.location.search);
var openId = params.get('open');
function tryBind() {
var page = document.querySelector('.ei-page');
if (page && page._x_dataStack && page._x_dataStack[0]) {
self.bindApp(page._x_dataStack[0]);
if (openId) self.open(openId);
return true;
}
if (global.__exportIntelApp) {
self.bindApp(global.__exportIntelApp);
if (openId) self.open(openId);
return true;
}
return false;
}
if (!tryBind()) {
var n = 0;
var timer = setInterval(function () {
n++;
if (tryBind() || n > 120) clearInterval(timer);
}, 100);
}
document.addEventListener('alpine:initialized', function () {
tryBind();
});
},
};
global.ExportIntelProfile = Profile;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { Profile.boot(); });
} else {
Profile.boot();
}
})(window);
+469 -56
View File
@@ -4,7 +4,15 @@ function exportIntelApp() {
tabIndicatorTop: 0,
tabIndicatorH: 40,
busy: false,
overlayDismissed: false,
syncAbort: null,
syncMsg: '',
syncMonitor: null,
syncMonitorOpen: true,
syncMonitorPoll: null,
googleMapsKey: '',
googleMapsKeySet: false,
googleKeySaving: false,
stats: {},
regions: [],
territories: [],
@@ -27,6 +35,7 @@ function exportIntelApp() {
mapHalalMin: 55,
mapError: '',
mapLoading: false,
tabMapLoading: false,
mapTruncated: false,
halalTopMarkets: [],
halalTopEntities: [],
@@ -37,6 +46,11 @@ function exportIntelApp() {
filterHasEmail: '',
filterFavoritesOnly: false,
filterCrmStatus: '',
filterConfidenceMin: '',
filterCity: '',
filterTenderStatus: '',
filterGovCategory: '',
_cityTimer: null,
selectedIds: [],
crmPipeline: [],
crmModalOpen: false,
@@ -58,12 +72,65 @@ function exportIntelApp() {
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
],
TYPE_GROUPS: {
b2b: ['distributor', 'wholesaler', 'importer', 'logistics', 'cold_storage', 'port_agent'],
horeca: ['restaurant', 'doner_shoarma', 'butcher', 'foodservice'],
caterer: ['contract_caterer'],
},
TYPE_CHIP_EXPAND: {
distributor: 'b2b',
importer: 'b2b',
logistics: 'b2b',
restaurant: 'horeca',
doner_shoarma: 'horeca',
butcher: 'horeca',
foodservice: 'horeca',
},
expandEntityTypes(typeOrList) {
var self = this;
var raw = Array.isArray(typeOrList) ? typeOrList : [typeOrList];
var out = [];
raw.forEach(function (v) {
if (!v) return;
var key = String(v).trim();
var group = self.TYPE_CHIP_EXPAND[key];
if (group && self.TYPE_GROUPS[group]) {
out = out.concat(self.TYPE_GROUPS[group]);
} else {
out.push(key);
}
});
return out.filter(function (v, i, a) { return a.indexOf(v) === i; });
},
mapTypesParam() {
if (this.entityTypeFilter) {
var expanded = this.expandEntityTypes(this.entityTypeFilter);
if (expanded.length === 1) return { entity_type: expanded[0] };
if (expanded.length) return { entity_types: expanded.join(',') };
}
if (this.mapTypesSelected.length) {
var all = this.expandEntityTypes(this.mapTypesSelected);
if (all.length === 1) return { entity_type: all[0] };
if (all.length) return { entity_types: all.join(',') };
}
return {};
},
shouldAutoFitMap(featCount) {
return !!(this.country || this.region || this.q || this.entityTypeFilter ||
this.mapTypesSelected.length || this.mapHalalMode || this.filterFavoritesOnly ||
this.filterCrmStatus || (featCount > 0 && featCount <= 800));
},
mapTypeChips: [
{ v: 'distributor', l: 'Distributeur', icon: '📦' },
{ v: 'distributor', l: 'B2B / distri', icon: '📦' },
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
{ v: 'importer', l: 'Importeur', icon: '🚢' },
{ v: 'logistics', l: 'Logistiek', icon: '🚚' },
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' },
{ v: 'restaurant', l: 'Horeca', icon: '🍽️' },
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
{ v: 'butcher', l: 'Slager', icon: '🥩' },
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
@@ -75,11 +142,13 @@ function exportIntelApp() {
var t = params.get('tab');
if (t) this.tab = t;
var self = this;
window.__exportIntelApp = self;
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.startSyncMonitor(true);
this.$nextTick(() => this.updateTabIndicator());
window.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select')) return;
@@ -94,6 +163,7 @@ function exportIntelApp() {
async loadMeta() {
try {
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
await this.loadGoogleSettings();
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
} catch (e) {
console.error(e);
@@ -141,6 +211,10 @@ function exportIntelApp() {
this.filterHasEmail = '';
this.filterFavoritesOnly = false;
this.filterCrmStatus = '';
this.filterConfidenceMin = '';
this.filterCity = '';
this.filterTenderStatus = '';
this.filterGovCategory = '';
this.selectedIds = [];
this.applyMapFilters();
},
@@ -153,6 +227,14 @@ function exportIntelApp() {
return this.tab === 'map';
},
mapTypeCount(v) {
var geo = (this.stats && this.stats.entity_types_geo) || {};
var expanded = this.expandEntityTypes(v);
var n = 0;
expanded.forEach(function (t) { n += Number(geo[t] || 0); });
return n;
},
isMapTypeActive(v) {
return this.mapTypesSelected.indexOf(v) >= 0;
},
@@ -170,14 +252,6 @@ function exportIntelApp() {
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();
@@ -187,11 +261,31 @@ function exportIntelApp() {
},
showEmailFilter() {
return this.tab === 'contacts';
return ['map', 'distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showCrmFilter() {
return this.tab === 'map' || this.tab === 'contacts';
return ['map', 'distributors', 'customers', 'caterers', 'contacts', 'pipeline'].indexOf(this.tab) >= 0;
},
showCollectionFilters() {
return this.showCrmFilter() || this.showFavoritesFilter();
},
showFavoritesFilter() {
return ['map', 'distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showConfidenceFilter() {
return this.tab === 'distributors';
},
showCityFilter() {
return ['distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showContextFilters() {
return this.showEntityTypeFilter() || this.showEmailFilter() || this.showConfidenceFilter() || this.showCityFilter() || this.tab === 'tenders' || this.tab === 'gov';
},
showSelectionBar() {
@@ -199,9 +293,30 @@ function exportIntelApp() {
},
isWorking() {
if (this.overlayDismissed) return false;
return this.busy || this.crmPushBusy || this.mapLoading || this.drawerLoading;
},
dismissOverlay() {
this.overlayDismissed = true;
},
async cancelSync() {
try {
if (this.syncAbort) {
try { this.syncAbort.abort(); } catch (e) {}
}
await fetch('/api/export-intel/sync/cancel', { method: 'POST' });
this.syncMsg = 'Sync wordt geannuleerd…';
this.busy = false;
this.overlayDismissed = true;
if (window.Cockpit) Cockpit.toast('Sync annuleren…', 'info');
await this.pollSyncStatus();
} catch (e) {
this.syncMsg = 'Annuleren mislukt: ' + e.message;
}
},
progressLabel() {
if (this.busy && this.syncMsg) return this.syncMsg;
if (this.crmPushBusy) return 'CRM import bezig…';
@@ -210,6 +325,75 @@ function exportIntelApp() {
return 'Bezig…';
},
displayEntities() {
var list = this.entities || [];
if (this.filterHasEmail === "yes") {
list = list.filter(function (e) {
return e.email || e.primary_email || e.phone || e.primary_phone || e.has_contact;
});
} else if (this.filterHasEmail === "no") {
list = list.filter(function (e) {
return !e.email && !e.primary_email && !e.phone && !e.primary_phone && !e.has_contact;
});
}
if (this.filterConfidenceMin) {
var min = Number(this.filterConfidenceMin);
list = list.filter(function (e) { return (e.confidence || 0) >= min; });
}
if (this.filterCity && this.filterCity.trim()) {
var c = this.filterCity.trim().toLowerCase();
list = list.filter(function (e) { return (e.city || "").toLowerCase().includes(c); });
}
return list;
},
displayCount() {
if (this.tab === "contacts") return (this.contacts || []).length;
return this.displayEntities().length;
},
displayPipeline() {
var list = this.crmPipeline || [];
if (this.q && this.q.trim()) {
var ql = this.q.trim().toLowerCase();
list = list.filter(function (p) { return (p.name || "").toLowerCase().includes(ql); });
}
return list;
},
hasActiveFilters() {
return !!(this.q || this.entityTypeFilter || this.mapTypesSelected.length || this.mapHalalMode ||
this.filterHasEmail || this.filterFavoritesOnly || this.filterCrmStatus ||
this.filterConfidenceMin || this.filterCity || this.filterTenderStatus || this.filterGovCategory);
},
applyEntityFilters() {
if (this.tab === "map") this.applyMapFilters();
else {
this.loadTabData();
}
},
onCityFilterInput() {
clearTimeout(this._cityTimer);
},
onPipelineFilter() {
clearTimeout(this._searchTimer);
},
applyGovFilters() {
this.loadTabData();
},
visibleEntities() {
if (this.tab === "map" && this.mapHalalMode && this.halalTopEntities.length) {
return this.halalTopEntities;
}
return this.displayEntities();
},
entityTypeOptions() {
if (this.tab === 'distributors') {
return [
@@ -232,6 +416,15 @@ function exportIntelApp() {
if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
}
if (this.tab === 'pipeline') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
];
}
if (this.tab === 'contacts') {
return [
{ v: '', l: 'Alle types' },
@@ -253,6 +446,91 @@ function exportIntelApp() {
];
},
tabMapId() {
var tabs = ['distributors', 'customers', 'caterers', 'contacts', 'pipeline'];
if (tabs.indexOf(this.tab) < 0) return null;
return 'ei-map-' + this.tab;
},
entitiesToGeojson(list) {
var features = [];
(list || []).forEach(function (e) {
if (e.lat == null || e.lon == null) return;
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [Number(e.lon), Number(e.lat)] },
properties: {
id: e.id,
name: e.name,
entity_type: e.entity_type,
country_iso2: e.country_iso2,
city: e.city,
address_line: e.address_line,
email: e.email || e.primary_email,
phone: e.phone || e.primary_phone,
website: e.website,
confidence: e.confidence,
halal_score: e.halal_score,
halal_tier: e.halal_tier,
contact_count: e.contact_count,
pipeline_stage: e.pipeline_stage,
volume_band: e.volume_band,
},
});
});
return { type: 'FeatureCollection', features: features };
},
async fetchTabMapBundle() {
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));
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
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');
var tabTypes = null;
if (this.tab === 'distributors') tabTypes = this.TYPE_GROUPS.b2b;
else if (this.tab === 'customers') tabTypes = this.TYPE_GROUPS.horeca;
else if (this.tab === 'caterers') tabTypes = this.TYPE_GROUPS.caterer;
if (this.entityTypeFilter) {
var ef = this.expandEntityTypes(this.entityTypeFilter);
tabTypes = ef;
}
if (tabTypes) {
if (tabTypes.length === 1) qs.push('entity_type=' + encodeURIComponent(tabTypes[0]));
else qs.push('entity_types=' + encodeURIComponent(tabTypes.join(',')));
}
if (this.tab === 'pipeline') qs.push('crm_linked=true');
if (qs.length) url += '?' + qs.join('&');
var bundle = await fetch(url).then(function (r) { return r.json(); });
this._lastMapBundleMeta = bundle.meta || {};
return bundle.entities || { type: 'FeatureCollection', features: [] };
},
async loadTabMap() {
var cid = this.tabMapId();
if (!cid || !document.getElementById(cid)) return;
if (!window.L || !window.ExportIntelMap) return;
this.tabMapLoading = true;
try {
var geo = await this.fetchTabMapBundle();
var featN = (geo.features || []).length;
var self = this;
await this.$nextTick();
window.ExportIntelMap.init(cid);
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); }, cid);
window.ExportIntelMap.setHalalMode(false, cid);
window.ExportIntelMap.setFeatures(geo, { autoFit: this.shouldAutoFitMap(featN) }, cid);
if (window.ExportIntelMap.invalidateAll) window.ExportIntelMap.invalidateAll();
} catch (e) {
console.error(e);
}
this.tabMapLoading = false;
},
async loadTabData(opts) {
opts = opts || {};
var base = '/api/export-intel';
@@ -260,20 +538,18 @@ function exportIntelApp() {
if (this.tab === 'map') {
return;
} else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics';
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
var types = this.expandEntityTypes(this.entityTypeFilter || 'b2b').join(',');
var r = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).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());
var types2 = this.expandEntityTypes(this.entityTypeFilter || 'horeca').join(',');
var r2 = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types2) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).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());
var types3 = this.expandEntityTypes(this.entityTypeFilter || 'contract_caterer').join(',');
var r3 = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types3) + '&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());
@@ -297,6 +573,9 @@ function exportIntelApp() {
var ql = this.q.trim().toLowerCase();
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
}
if (this.filterTenderStatus) {
items = items.filter((t) => (t.status || '').toLowerCase() === this.filterTenderStatus);
}
this.tenders = items;
this.resultCount = items.length;
} else if (this.tab === 'gov') {
@@ -313,6 +592,9 @@ function exportIntelApp() {
this.crmPipeline = pipe.items || [];
this.resultCount = this.crmPipeline.length;
}
if (['distributors','customers','caterers','contacts','pipeline'].indexOf(this.tab) >= 0) {
this.$nextTick(() => this.loadTabMap());
}
},
async loadMap() {
@@ -355,16 +637,25 @@ function exportIntelApp() {
if (!document.getElementById('ei-map')) { this.mapLoading = false; return; }
var self = this;
window.ExportIntelMap.init('ei-map');
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
var autoFit = !!(this.country || this.region);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit });
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); }, 'ei-map');
window.ExportIntelMap.setHalalMode(this.mapHalalMode, 'ei-map');
var feats = (bundle.entities && bundle.entities.features) || [];
var autoFit = this.shouldAutoFitMap(feats.length);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit }, 'ei-map');
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);
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6, 'ei-map');
}
}
this.mapLoading = false;
var self2 = this;
this.$nextTick(function () {
requestAnimationFrame(function () {
if (window.ExportIntelMap && window.ExportIntelMap.invalidateAll) {
window.ExportIntelMap.invalidateAll();
}
});
});
},
setTab(id, el) {
@@ -378,6 +669,9 @@ function exportIntelApp() {
history.replaceState(null, '', u.pathname + u.search);
this.loadTabData();
if (id === 'map') this.$nextTick(() => this.loadMap());
else if (['distributors','customers','caterers','contacts','pipeline'].indexOf(id) >= 0) {
this.$nextTick(() => this.loadTabMap());
}
this.$nextTick(() => this.updateTabIndicator(el));
},
@@ -440,34 +734,20 @@ function exportIntelApp() {
this.listFocusDetail = null;
},
async openEntity(id) {
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;
if (window.ExportIntelProfile && typeof window.ExportIntelProfile.open === 'function') {
window.ExportIntelProfile.open(id);
return;
}
this.drawerLoading = false;
this.syncMsg = 'Profiel-module niet geladen — ververs de pagina';
},
closeDrawer() {
if (window.ExportIntelProfile && typeof window.ExportIntelProfile.close === 'function') {
window.ExportIntelProfile.close();
return;
}
this.drawerOpen = false;
this.selected = null;
this.drawerLoading = false;
@@ -528,36 +808,48 @@ function exportIntelApp() {
async syncKind(kind) {
if (kind === 'world') {
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return;
if (!confirm('Wereld-sync: alle regio\'s (Europa, MENA, Afrika, Amerika, Asia, Oceanië). OSM + horeca + contacten — kan uren duren. Doorgaan?')) return;
}
if (kind === 'region' && !this.region) {
this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
return;
}
this.busy = true;
this.overlayDismissed = false;
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
this.syncAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
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 };
if (kind === 'world') body = { max_priority: 3 };
if (kind === 'places' && !this.country && !this.region) {
this.syncMsg = 'Selecteer een land of regio voor Google Places';
this.busy = false;
return;
}
var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, {
var fetchOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
};
if (this.syncAbort) fetchOpts.signal = this.syncAbort.signal;
var r = await fetch('/api/export-intel/sync/' + endpoint, fetchOpts).then((x) => x.json());
if (r.error) this.syncMsg = 'Fout: ' + r.error;
else if (r.cancelled || (r.result && r.result.cancelled)) this.syncMsg = 'Sync geannuleerd';
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();
if (!(r.cancelled || (r.result && r.result.cancelled))) await this.refresh();
} catch (e) {
this.syncMsg = 'Sync mislukt: ' + e.message;
if (e && e.name === 'AbortError') this.syncMsg = 'Sync geannuleerd';
else this.syncMsg = 'Sync mislukt: ' + e.message;
}
this.busy = false;
this.syncAbort = null;
},
tabCount(id) {
@@ -604,7 +896,128 @@ function exportIntelApp() {
},
typeLabel(t) {
return (t || '').replace(/_/g, ' ');
var labels = {
distributor: 'Distrib.',
wholesaler: 'Grooth.',
contract_caterer: 'Cateraar',
end_customer: 'Eindkl.',
hotel: 'Hotel',
restaurant: 'Rest.',
retailer: 'Retail',
importer: 'Import',
};
return labels[t] || (t || '').replace(/_/g, ' ');
},
filteredTerritories() {
var list = (this.territories || []).filter(function (t) {
return !this.region || t.region_code === this.region;
}, this);
var seen = {};
var out = [];
list.forEach(function (t) {
var iso = t.country_iso2;
if (!iso || seen[iso]) return;
seen[iso] = true;
out.push(t);
});
out.sort(function (a, b) {
return (a.name_nl || a.country_iso2 || '').localeCompare(b.name_nl || b.country_iso2 || '', 'nl');
});
return out;
},
countryEntityCount(iso2) {
if (!iso2) return 0;
var cc = (this.stats && this.stats.country_entity_counts) || {};
return Number(cc[iso2] || 0);
},
countryOptionLabel(t) {
if (!t) return '';
var n = this.countryEntityCount(t.country_iso2);
return n > 0 ? (t.name_nl + ' (' + n + ')') : t.name_nl;
},
async loadGoogleSettings() {
try {
var s = await fetch('/api/export-intel/settings').then(function (r) { return r.json(); });
this.googleMapsKeySet = !!(s && s.google_maps_api_key_set);
this.googleMapsKey = '';
} catch (e) { console.error(e); }
},
async saveGoogleMapsKey() {
if (!this.googleMapsKey || !this.googleMapsKey.trim()) return;
this.googleKeySaving = true;
try {
await fetch('/api/export-intel/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ google_maps_api_key: this.googleMapsKey.trim() }),
});
this.googleMapsKey = '';
this.googleMapsKeySet = true;
this.syncMsg = 'Google Places API-key opgeslagen';
} catch (e) {
this.syncMsg = 'API-key opslaan mislukt';
}
this.googleKeySaving = false;
},
startSyncMonitor(silent) {
var self = this;
if (this.syncMonitorPoll) return;
this.pollSyncStatus();
this.syncMonitorPoll = setInterval(function () { self.pollSyncStatus(); }, 3000);
},
stopSyncMonitor() {
if (this.syncMonitorPoll) {
clearInterval(this.syncMonitorPoll);
this.syncMonitorPoll = null;
}
},
async pollSyncStatus() {
try {
var s = await fetch('/api/export-intel/sync/status').then(function (r) { return r.json(); });
this.syncMonitor = s;
if (s.any_running && s.active_jobs && s.active_jobs.length) {
var j = s.active_jobs[0];
this.syncMsg = (j.message || j.label || 'Sync bezig') + (j.percent ? ' · ' + j.percent + '%' : '');
}
} catch (e) {
console.error(e);
}
},
toggleSyncMonitor() {
this.syncMonitorOpen = !this.syncMonitorOpen;
},
syncMonitorPercent() {
var jobs = (this.syncMonitor && this.syncMonitor.active_jobs) || [];
if (!jobs.length) return 0;
return jobs[0].percent || 0;
},
formatSyncTime(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
syncEventLabel(ev) {
if (!ev) return '';
var parts = [ev.event || ''];
if (ev.message) parts.push(ev.message);
else if (ev.country) parts.push(ev.country);
if (ev.step) parts.push('(' + ev.step + ')');
return parts.filter(Boolean).join(' · ');
},
territoryName(iso2) {
@@ -644,7 +1057,7 @@ function exportIntelApp() {
}
return;
}
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
var list = this.visibleEntities();
if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id));
if (allSelected) {
+357 -9
View File
@@ -21,6 +21,87 @@ window.HermanAssistant = (function () {
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
}
var STORAGE_OPEN = 'herman_assistant_open';
var STORAGE_CHAT = 'herman_assistant_chat_v1';
var STORAGE_PREFS = 'herman_assistant_prefs_v1';
var STORAGE_FAB = 'herman_assistant_fab_pos_v1';
var MAX_STORED_TURNS = 40;
var _cachedVoice = null;
function loadPrefs() {
try {
var raw = localStorage.getItem(STORAGE_PREFS);
if (!raw) return {};
var data = JSON.parse(raw);
return data && typeof data === 'object' ? data : {};
} catch (e) {
return {};
}
}
function pickMaartenVoice() {
if (!window.speechSynthesis) return null;
if (_cachedVoice) return _cachedVoice;
var voices = window.speechSynthesis.getVoices() || [];
if (!voices.length) return null;
var prefer = [
/maarten/i,
/microsoft.*nederlands/i,
/google.*nederlands/i,
/nl[-_]NL/i,
/dutch/i,
];
for (var p = 0; p < prefer.length; p++) {
for (var i = 0; i < voices.length; i++) {
var v = voices[i];
var hay = ((v.name || '') + ' ' + (v.lang || '') + ' ' + (v.voiceURI || ''));
if (prefer[p].test(hay)) {
_cachedVoice = v;
return v;
}
}
}
for (var j = 0; j < voices.length; j++) {
if (/^nl/i.test(voices[j].lang || '')) {
_cachedVoice = voices[j];
return voices[j];
}
}
return null;
}
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.addEventListener('voiceschanged', function () {
_cachedVoice = null;
pickMaartenVoice();
});
}
function loadStoredChat(sessionPrefix) {
try {
var raw = localStorage.getItem(STORAGE_CHAT);
if (!raw) return null;
var data = JSON.parse(raw);
if (!data || !Array.isArray(data.turns)) return null;
if (sessionPrefix && data.sessionPrefix && data.sessionPrefix !== sessionPrefix) return null;
return data;
} catch (e) {
return null;
}
}
function saveStoredChat(state) {
try {
var turns = (state.turns || []).slice(-MAX_STORED_TURNS);
localStorage.setItem(STORAGE_CHAT, JSON.stringify({
sessionPrefix: state.sessionPrefix,
sessionId: state.sessionId,
turns: turns,
savedAt: Date.now(),
}));
} catch (e) { /* empty */ }
}
function baseState(opts) {
opts = opts || {};
return {
@@ -30,12 +111,20 @@ window.HermanAssistant = (function () {
hidden: false,
status: 'idle',
statusDetail: '',
connOk: null,
connLabel: 'Herman',
livePreview: '',
liveInterim: false,
recording: false,
busy: false,
speakReply: true,
speakReply: false,
handsFreeMode: false,
httpsAudioPrompt: false,
fabPos: null,
fabDragging: false,
_fabMoved: false,
_fabPointerId: null,
_fabOrigin: null,
useBrowserPreview: !!SpeechRecognition,
turns: [],
manualText: '',
@@ -65,7 +154,209 @@ window.HermanAssistant = (function () {
toggleOpen: function () {
this.open = !this.open;
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
try { localStorage.setItem(STORAGE_OPEN, this.open ? '1' : '0'); } catch (e) { /* empty */ }
if (this.open) this.scrollLog();
},
loadFabPos: function () {
try {
var raw = localStorage.getItem(STORAGE_FAB);
if (!raw) return;
var p = JSON.parse(raw);
if (p && typeof p.x === 'number' && typeof p.y === 'number') this.fabPos = p;
} catch (e) { /* empty */ }
},
saveFabPos: function () {
try {
if (!this.fabPos) localStorage.removeItem(STORAGE_FAB);
else localStorage.setItem(STORAGE_FAB, JSON.stringify(this.fabPos));
} catch (e) { /* empty */ }
},
resetFabPos: function () {
this.fabPos = null;
this.saveFabPos();
},
fabStyle: function () {
if (!this.fabPos) return {};
return {
left: this.fabPos.x + 'px',
top: this.fabPos.y + 'px',
right: 'auto',
bottom: 'auto',
transform: this.fabDragging ? 'none' : undefined,
};
},
panelStyle: function () {
if (!this.fabPos) return {};
var vw = window.innerWidth || 800;
var vh = window.innerHeight || 600;
var panelW = Math.min(420, vw - 24);
var panelH = Math.min(620, vh - 96);
var fabX = this.fabPos.x;
var fabY = this.fabPos.y;
var fabW = 130;
var fabH = 52;
var left = Math.min(Math.max(8, fabX + fabW / 2 - panelW / 2), vw - panelW - 8);
var top = fabY - panelH - 12;
if (top < 8) top = fabY + fabH + 12;
if (top + panelH > vh - 8) top = Math.max(8, vh - panelH - 8);
return {
left: left + 'px',
top: top + 'px',
right: 'auto',
bottom: 'auto',
};
},
_clampFab: function (x, y) {
var vw = window.innerWidth || 800;
var vh = window.innerHeight || 600;
var w = 56;
var h = 52;
return {
x: Math.min(Math.max(8, x), Math.max(8, vw - w - 8)),
y: Math.min(Math.max(8, y), Math.max(8, vh - h - 8)),
};
},
onFabPointerDown: function (e) {
if (e.button != null && e.button !== 0) return;
var point = e.touches && e.touches[0] ? e.touches[0] : e;
var el = e.currentTarget;
var rect = el.getBoundingClientRect();
this._fabMoved = false;
this.fabDragging = true;
this._fabOrigin = {
px: point.clientX,
py: point.clientY,
left: this.fabPos ? this.fabPos.x : rect.left,
top: this.fabPos ? this.fabPos.y : rect.top,
};
if (el.setPointerCapture && e.pointerId != null) {
try { el.setPointerCapture(e.pointerId); } catch (err) { /* empty */ }
}
},
onFabPointerMove: function (e) {
if (!this.fabDragging || !this._fabOrigin) return;
var point = e.touches && e.touches[0] ? e.touches[0] : e;
var dx = point.clientX - this._fabOrigin.px;
var dy = point.clientY - this._fabOrigin.py;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) this._fabMoved = true;
if (!this._fabMoved) return;
e.preventDefault();
this.fabPos = this._clampFab(this._fabOrigin.left + dx, this._fabOrigin.top + dy);
},
onFabPointerUp: function (e) {
if (!this.fabDragging) return;
var moved = this._fabMoved;
this.fabDragging = false;
this._fabOrigin = null;
if (moved) {
this.saveFabPos();
e.preventDefault();
e.stopPropagation();
return;
}
this.toggleOpen();
},
onFabClick: function (e) {
// click handled in pointerup; prevent double-toggle
e.preventDefault();
e.stopPropagation();
},
onFabDblClick: function (e) {
e.preventDefault();
this.resetFabPos();
Cockpit.toast('Herman-knop teruggezet', 'info');
},
persistChat: function () {
saveStoredChat(this);
},
persistPrefs: function () {
try {
localStorage.setItem(STORAGE_PREFS, JSON.stringify({
speakReply: !!this.speakReply,
handsFreeMode: !!this.handsFreeMode,
}));
} catch (e) { /* empty */ }
if (!this.speakReply && window.speechSynthesis) {
try { window.speechSynthesis.cancel(); } catch (e2) { /* empty */ }
}
},
applyPrefs: function () {
var prefs = loadPrefs();
if (typeof prefs.speakReply === 'boolean') this.speakReply = prefs.speakReply;
if (typeof prefs.handsFreeMode === 'boolean') this.handsFreeMode = prefs.handsFreeMode;
},
onHandsFreeChange: function () {
this.persistPrefs();
if (this.handsFreeMode && !this.micSecureOk()) {
this.offerHttpsAudio();
return;
}
if (this.handsFreeMode && !this.recording && !this.busy) {
this.statusDetail = 'Hands-free — luisteren…';
this.startListen();
} else if (!this.handsFreeMode && this.recording) {
this.stopListen();
this.statusDetail = '';
}
},
offerHttpsAudio: function () {
this.httpsAudioPrompt = true;
this.status = 'idle';
this.statusDetail = '';
if (this.handsFreeMode) {
this.handsFreeMode = false;
this.persistPrefs();
}
},
goHttpsAudio: function () {
var url = this.httpsChatUrl();
this.httpsAudioPrompt = false;
window.open(url, '_blank', 'noopener');
},
micSecureOk: function () {
return !!(window.isSecureContext || location.hostname === 'localhost' || location.hostname === '127.0.0.1');
},
httpsChatUrl: function () {
var host = location.hostname || '10.4.7.18';
var path = location.pathname || '/';
var q = location.search || '';
return 'https://' + host + ':8643' + path + q;
},
clearChat: function () {
this.turns = [];
this.manualText = '';
this.pendingAction = null;
this.sessionId = newSessionId(this.sessionPrefix);
this.statusDetail = '';
this.status = 'idle';
this.persistChat();
},
markConn: function (ok, label) {
this.connOk = ok;
this.connLabel = label || (ok ? 'Verbonden met Herman' : 'Herman niet bereikbaar');
},
scrollLog: function () {
@@ -78,6 +369,7 @@ window.HermanAssistant = (function () {
addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.persistChat();
this.scrollLog();
},
@@ -86,8 +378,10 @@ window.HermanAssistant = (function () {
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) {
var label = (h.agent_label || 'Herman').replace(/\s*[·•].*$/, '').trim() || 'Herman';
if (/hermes/i.test(label)) label = 'Herman';
this.addTurn('agent', h.reply, {
agent_label: h.agent_label,
agent_label: label,
delegated: h.delegated_agents,
routing_reason: h.routing_reason,
});
@@ -111,6 +405,8 @@ window.HermanAssistant = (function () {
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
var voice = pickMaartenVoice();
if (voice) u.voice = voice;
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
@@ -208,7 +504,12 @@ window.HermanAssistant = (function () {
async startListen() {
if (this.recording || this.busy) return;
if (!this.micSecureOk()) {
this.offerHttpsAudio();
return;
}
var self = this;
this.httpsAudioPrompt = false;
this.livePreview = '';
this.status = 'listening';
this.statusDetail = 'Luisteren…';
@@ -240,8 +541,12 @@ window.HermanAssistant = (function () {
}
} catch (e) {
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast('Microfoon: ' + e.message, 'error');
var msg = e.message || String(e);
if (!this.micSecureOk()) {
msg = 'Mic alleen via HTTPS — gebruik de knop hierboven.';
}
this.statusDetail = msg;
Cockpit.toast('Microfoon: ' + msg, 'error');
}
},
@@ -291,6 +596,14 @@ window.HermanAssistant = (function () {
this.status = 'idle';
this.statusDetail = '';
} catch (e) {
var preview = (this.livePreview || '').trim();
if (preview) {
this.manualText = preview;
this.busy = false;
this.statusDetail = 'Browser-spraak gebruikt (Whisper offline)';
Cockpit.toast('Whisper offline — tekst via browser-spraak', 'info');
return this.sendText();
}
this.status = 'error';
this.statusDetail = e.message;
Cockpit.toast(e.message, 'error');
@@ -301,23 +614,37 @@ window.HermanAssistant = (function () {
async sendText(confirmActionId) {
var text = (this.manualText || '').trim();
if (!text) return;
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.busy = true;
this.status = 'processing';
this.statusDetail = 'Herman denkt…';
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';
var reply = (r && r.reply) || '';
var failed = /niet bereikbaar|niet beschikbaar|offline/i.test(reply);
this.markConn(!failed, failed ? 'Herman niet bereikbaar' : 'Verbonden met Herman');
this.status = failed ? 'error' : 'idle';
this.statusDetail = failed ? reply.slice(0, 120) : '';
} catch (e) {
Cockpit.toast(e.message, 'error');
this.status = 'error';
this.statusDetail = e.message;
this.markConn(false, 'Herman offline');
}
this.busy = false;
},
openWarRoomPopup: function () {
if (window.WarRoomHub && WarRoomHub.openPopup) {
WarRoomHub.openPopup();
return;
}
window.open('/agents/warroom/popup', 'flk-warroom', 'width=980,height=780,menubar=no,toolbar=no,location=no,status=no');
},
formatDelegated: function (meta) {
var d = (meta && meta.delegated) || [];
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
@@ -331,10 +658,21 @@ window.HermanAssistant = (function () {
s.initWidget = function () {
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
this.applyPrefs();
this.loadFabPos();
pickMaartenVoice();
try {
var raw = localStorage.getItem('herman_assistant_open');
var raw = localStorage.getItem(STORAGE_OPEN);
if (raw === '1') this.open = true;
} catch (e) { /* empty */ }
var stored = loadStoredChat(this.sessionPrefix);
if (stored) {
if (stored.sessionId) this.sessionId = stored.sessionId;
if (stored.turns && stored.turns.length) this.turns = stored.turns;
}
this.connOk = null;
this.connLabel = 'Herman';
if (this.open) this.scrollLog();
};
return s;
},
@@ -345,6 +683,8 @@ window.HermanAssistant = (function () {
s.lastImage = '';
s.initChat = function () {
this.open = true;
this.applyPrefs();
pickMaartenVoice();
};
s.generateImage = async function () {
if (!this.imagePrompt.trim()) return;
@@ -371,3 +711,11 @@ window.HermanAssistant = (function () {
function hermanBrowserWidget() {
return HermanAssistant.browserWidget();
}
document.addEventListener('alpine:init', function () {
if (window.Alpine && typeof Alpine.data === 'function') {
Alpine.data('hermanBrowserWidget', function () {
return HermanAssistant.browserWidget();
});
}
});
+183 -296
View File
@@ -1,346 +1,233 @@
/**
* Hermes Neo Command Center
* Hermes technical KPI dashboard + bucket explorer popup
*/
function hermesControl() {
const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck'];
/** Canonical team — never duplicate role as name */
const ROSTER = [
{ chat_id: 8859782446, role: 'CEO', name: 'Aïssa' },
{ chat_id: 789036463, role: 'CTO', name: 'Mo' },
];
function hermesKpiDashboard() {
const SOURCE_LABELS = {
telegram: 'Telegram',
api_server: 'Cockpit API',
tui: 'TUI',
cron: 'Cron',
subagent: 'Subagent',
};
return {
tab: 'feed',
live: true,
stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 },
conversations: [],
messages: [],
users: [],
agentEvents: [],
selectedChatId: null,
graphChatFilter: '',
searchQuery: '',
searchResults: [],
searchRan: false,
graphNetwork: null,
pollTimer: null,
paPollTimer: null,
paPolling: false,
paLive: { status: 'idle', query: '', slots: [] },
paScreenshotTs: Date.now(),
hermesOnline: false,
hermesStatus: {},
actionBusy: false,
actionMsg: '',
chartDirection: null,
chartAgents: null,
get teamRoster() {
const byId = {};
(this.users || []).forEach(u => { byId[u.chat_id] = u; });
(this.conversations || []).forEach(c => {
if (!byId[c.chat_id]) byId[c.chat_id] = c;
else {
byId[c.chat_id].message_count = c.message_count || byId[c.chat_id].message_count;
}
});
return ROSTER.map(r => ({
...r,
online: !!(byId[r.chat_id]?.online),
pa_mode: !!(byId[r.chat_id]?.pa_mode),
message_count: byId[r.chat_id]?.message_count || 0,
}));
loading: false,
kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] },
chartTools: null,
chartSources: null,
_timer: null,
modal: {
open: false,
loading: false,
previewLoading: false,
key: null,
title: '',
tab: 'activity',
toolFilter: null,
detail: { events: [], files: [], parts: {}, count: 0 },
explorer: { path: '', parent: null, entries: [] },
selectedEvent: null,
preview: null,
},
get paLiveUser() {
const cid = this.paLive.chat_id;
if (!cid) return '';
const m = ROSTER.find(r => r.chat_id === cid);
return m ? `${m.role} — ${m.name}` : this.paLive.user_name || '';
get ov() { return this.kpis.overview || {}; },
fmt(n) {
const v = Number(n || 0);
return v.toLocaleString('nl-NL');
},
get paSlots() {
const slots = this.paLive.slots || [];
if (slots.length >= 4) return slots;
const byLabel = {};
slots.forEach(s => { byLabel[s.label] = s; });
return PA_SITES.map(label => byLabel[label] || {
label, status: 'idle', has_screenshot: false,
});
},
rosterPerson(chatId) {
return ROSTER.find(r => r.chat_id === Number(chatId)) || null;
},
msgLabel(msg) {
const p = this.rosterPerson(msg.chat_id);
if (p) return `${p.role} (${p.name})`;
if (msg.user_role && msg.user_name) return `${msg.user_role} (${msg.user_name})`;
return msg.user_role || msg.chat_id || '?';
},
async init() {
const hash = (window.location.hash || '').replace('#', '');
if (['feed', 'pa', 'graph', 'search', 'control'].includes(hash)) this.tab = hash;
await this.refreshStats();
await this.refreshConversations();
await this.refreshUsers();
await this.refreshFeed();
await this.refreshPaLive();
setTimeout(() => this.initCharts(), 200);
this.pollTimer = setInterval(() => {
if (this.tab === 'feed' && this.live) this.refreshFeed(true);
}, 5000);
this.paPollTimer = setInterval(() => {
if (this.tab === 'pa' || this.paLive.status === 'running') this.refreshPaLive(true);
}, 2000);
window.addEventListener('hashchange', () => {
const h = (window.location.hash || '').replace('#', '');
if (['feed', 'pa', 'graph', 'search', 'control'].includes(h)) this.tab = h;
});
},
initCharts() {
if (typeof Chart === 'undefined') return;
const neoColors = ['#00e5ff', '#ff2d95', '#b8ff3c', '#ff9f43', '#a855f7', '#ffd700'];
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = 'rgba(0,229,255,0.08)';
const dirEl = document.getElementById('hm-chart-direction');
if (dirEl && !this.chartDirection) {
this.chartDirection = new Chart(dirEl, {
type: 'doughnut',
data: {
labels: ['Inbound', 'Outbound', 'Vectors'],
datasets: [{
data: [1, 1, 1],
backgroundColor: ['#00e5ff', '#a855f7', '#b8ff3c'],
borderWidth: 0,
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'right', labels: { boxWidth: 12, padding: 10, color: '#94a3b8' } },
},
},
});
}
const agEl = document.getElementById('hm-chart-agents');
if (agEl && !this.chartAgents) {
this.chartAgents = new Chart(agEl, {
type: 'bar',
data: {
labels: ['herman', 'browser', 'marketing'],
datasets: [{
label: 'events',
data: [0, 0, 0],
backgroundColor: 'rgba(0, 229, 255, 0.55)',
borderColor: '#00e5ff',
borderWidth: 1,
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { stepSize: 1 } },
x: { grid: { display: false } },
},
plugins: { legend: { display: false } },
},
});
}
this.updateCharts();
},
updateCharts() {
const s = this.stats;
if (this.chartDirection) {
this.chartDirection.data.datasets[0].data = [
Math.max(s.inbound || 0, 0),
Math.max(s.outbound || 0, 0),
Math.max(s.embeddings || 0, 0),
];
this.chartDirection.update('none');
}
if (this.chartAgents) {
const counts = {};
(this.agentEvents || []).forEach(ev => {
const n = (ev.agent_name || 'other').toLowerCase();
counts[n] = (counts[n] || 0) + 1;
});
const labels = Object.keys(counts).slice(0, 8);
if (!labels.length) labels.push('herman');
this.chartAgents.data.labels = labels;
this.chartAgents.data.datasets[0].data = labels.map(l => counts[l] || 0);
this.chartAgents.update('none');
}
},
setTab(t) {
this.tab = t;
history.replaceState(null, '', t === 'feed' ? '/hermes' : '/hermes#' + t);
if (t === 'graph') setTimeout(() => this.loadGraph(), 100);
if (t === 'control') { this.refreshStats(); this.refreshUsers(); }
if (t === 'pa') { this.paPolling = true; this.refreshPaLive(); }
fmtBytes(n) {
if (n == null || n === '') return '';
const v = Number(n);
if (v < 1024) return v + ' B';
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
return (v / (1024 * 1024)).toFixed(1) + ' MB';
},
formatTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch { return iso; }
} catch (e) { return String(iso).slice(0, 19); }
},
paStatusLabel() {
const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' };
return m[this.paLive.status] || this.paLive.status || 'Idle';
partsLine(parts) {
if (!parts) return '';
return Object.keys(parts)
.filter((k) => parts[k] > 0)
.map((k) => parts[k] + ' ' + k.replace(/_/g, ' '))
.join(' · ');
},
slotStatusLabel(s) {
const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' };
return m[s] || s || 'Idle';
sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; },
async init() {
await this.refresh();
this._timer = setInterval(() => this.refresh(true), 15000);
},
slotScreenshotUrl(label) {
if (!label) return '';
return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`;
},
async selectChat(chatId) {
this.selectedChatId = chatId;
await this.refreshFeed();
},
async refreshStats() {
async refresh(quiet) {
if (!quiet) this.loading = true;
try {
const data = await Cockpit.api('/hermes/stats');
this.stats = data.stats || this.stats;
this.agentEvents = data.agent_events || [];
this.updateCharts();
} catch (e) { console.warn('stats', e); }
const data = await Cockpit.api('/hermes/kpis');
this.kpis = data || this.kpis;
this.$nextTick(() => this.renderCharts());
} catch (e) {
if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error');
this.kpis.online = false;
}
this.loading = false;
},
async refreshUsers() {
try {
const data = await Cockpit.api('/hermes/users');
this.users = data.users || [];
} catch (e) { console.warn('users', e); }
async openBucket(b) {
this.modal.open = true;
this.modal.key = b.key;
this.modal.title = b.label;
this.modal.tab = b.key === 'files' ? 'touched' : 'activity';
this.modal.toolFilter = null;
this.modal.selectedEvent = null;
this.modal.preview = null;
this.modal.detail = { events: [], files: [], parts: b.parts || {}, count: b.count || 0 };
await this.loadBucketDetail();
if (b.key === 'files') {
// preload roots quietly
this.loadExplorer('').catch(() => {});
}
},
async refreshConversations() {
try {
const data = await Cockpit.api('/hermes/conversations');
this.conversations = data.items || [];
} catch (e) { console.warn('conversations', e); }
closeModal() {
this.modal.open = false;
this.modal.preview = null;
this.modal.selectedEvent = null;
},
async refreshFeed(quiet) {
try {
let path = '/hermes/feed?limit=80';
if (this.selectedChatId) path += '&chat_id=' + this.selectedChatId;
const data = await Cockpit.api(path);
this.messages = data.items || [];
if (!quiet) await this.refreshStats();
} catch (e) { console.warn('feed', e); }
async setToolFilter(tool) {
this.modal.toolFilter = tool;
await this.loadBucketDetail();
},
async refreshPaLive(quiet) {
async loadBucketDetail() {
if (!this.modal.key) return;
this.modal.loading = true;
try {
const data = await Cockpit.api('/hermes/pa/live');
this.paLive = data;
this.paScreenshotTs = Date.now();
if (data.hermes) {
this.hermesOnline = data.hermes.online !== false;
this.hermesStatus = data.hermes;
const q = new URLSearchParams({ limit: '80' });
if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter);
const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString());
this.modal.detail = data || this.modal.detail;
if (!(this.modal.detail.events || []).length) {
this.modal.selectedEvent = null;
}
} catch (e) {
if (!quiet) console.warn('pa live', e);
if (window.Cockpit) Cockpit.toast(e.message || 'Detail laden mislukt', 'error');
}
this.modal.loading = false;
},
selectEvent(ev) {
this.modal.selectedEvent = ev;
this.modal.preview = null;
if (ev.paths && ev.paths.length === 1 && this.modal.key === 'files') {
this.openFile(ev.paths[0]);
}
},
async loadGraph() {
openExplorerTab() {
this.modal.tab = 'explorer';
if (!(this.modal.explorer.entries || []).length) {
this.loadExplorer(this.modal.explorer.path || '');
}
},
async loadExplorer(path) {
this.modal.loading = true;
try {
let data;
if (this.graphChatFilter) {
data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80');
} else {
data = await Cockpit.api('/hermes/graph?limit=100');
}
this.renderGraph(data);
const q = path ? ('?path=' + encodeURIComponent(path)) : '';
const data = await Cockpit.api('/hermes/explorer' + q);
this.modal.explorer = data || { path: '', parent: null, entries: [] };
} catch (e) {
Cockpit.toast('Graph laden mislukt: ' + e.message, 'error');
if (window.Cockpit) Cockpit.toast(e.message || 'Verkenner laden mislukt', 'error');
}
this.modal.loading = false;
},
renderGraph(data) {
const el = document.getElementById('hermes-graph');
if (!el || typeof vis === 'undefined') return;
const nodes = new vis.DataSet((data.nodes || []).map(n => ({
id: n.id,
label: this.truncate(n.label || n.id, 40),
color: n.direction === 'in'
? { background: '#164e63', border: '#00e5ff' }
: { background: '#4c1d95', border: '#a855f7' },
font: { color: '#e8edf5', size: 11 },
})));
const edges = new vis.DataSet((data.edges || []).map(e => ({
id: 'e' + e.id, from: e.from, to: e.to,
label: e.type || '', arrows: 'to',
color: { color: '#ff9f43' },
})).filter(e => e.to));
if (this.graphNetwork) this.graphNetwork.destroy();
this.graphNetwork = new vis.Network(el, { nodes, edges }, {
physics: { stabilization: true },
interaction: { hover: true },
});
explorerUp() {
if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent);
else this.loadExplorer('');
},
truncate(s, n) {
s = String(s || '');
return s.length > n ? s.slice(0, n) + '…' : s;
},
async runSearch() {
if (!this.searchQuery || this.searchQuery.length < 2) {
Cockpit.toast('Minimaal 2 tekens', 'warn');
return;
}
this.searchRan = true;
async openFile(path) {
if (!path) return;
this.modal.previewLoading = true;
this.modal.selectedEvent = null;
try {
const data = await Cockpit.api('/hermes/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }),
const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path));
this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() };
if (this.modal.key === 'files' && this.modal.tab === 'activity') {
// keep tab; preview still shows
}
} catch (e) {
this.modal.preview = { path, name: path.split('/').pop(), error: e.message || 'Laden mislukt', content: null };
}
this.modal.previewLoading = false;
},
renderCharts() {
if (typeof Chart === 'undefined') return;
const tools = (this.kpis.tools_top || []).slice(0, 8);
const sources = this.kpis.sources || [];
const toolsCtx = document.getElementById('hm-chart-tools');
if (toolsCtx) {
if (this.chartTools) this.chartTools.destroy();
this.chartTools = new Chart(toolsCtx, {
type: 'bar',
data: {
labels: tools.map((t) => t.name),
datasets: [{
label: 'Calls',
data: tools.map((t) => t.count),
backgroundColor: 'rgba(14, 165, 233, 0.55)',
borderColor: 'rgba(56, 189, 248, 0.9)',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
this.searchResults = data.results || [];
} catch (e) {
Cockpit.toast('Zoeken mislukt: ' + e.message, 'error');
}
},
async runAction(action) {
this.actionBusy = true;
this.actionMsg = '';
try {
const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' });
this.actionMsg = data.output || '✅ Actie uitgevoerd';
Cockpit.toast(this.actionMsg.slice(0, 80), 'success');
await this.refreshStats();
} catch (e) {
this.actionMsg = '❌ ' + e.message;
Cockpit.toast(this.actionMsg, 'error');
} finally {
this.actionBusy = false;
const srcCtx = document.getElementById('hm-chart-sources');
if (srcCtx) {
if (this.chartSources) this.chartSources.destroy();
this.chartSources = new Chart(srcCtx, {
type: 'doughnut',
data: {
labels: sources.map((s) => this.sourceLabel(s.source)),
datasets: [{
data: sources.map((s) => s.tools || 0),
backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } },
},
},
});
}
},
};
}
function hermesControl() { return hermesKpiDashboard(); }
+54
View File
@@ -5,6 +5,7 @@ function revenueCockpit() {
savedAt: '',
_pollStop: null,
viewMode: 'cockpit',
visualMode: 'table',
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
projects: [],
dbProjects: [],
@@ -45,6 +46,12 @@ function revenueCockpit() {
async init() {
localStorage.setItem('foodlinkk-persona', 'ceo');
const saved = localStorage.getItem('rc-visual-mode');
if (saved === 'table' || saved === 'bars' || saved === 'cards') {
this.visualMode = saved;
} else if (window.matchMedia('(max-width: 768px)').matches) {
this.visualMode = 'cards';
}
await this.loadLive();
await this.loadDbQuiet();
if (window.CockpitLive) {
@@ -126,6 +133,53 @@ function revenueCockpit() {
return t.length <= n ? t : t.slice(0, n).trim() + '…';
},
setVisualMode(mode) {
if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return;
this.visualMode = mode;
localStorage.setItem('rc-visual-mode', mode);
},
styleLabel(style) {
const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' };
return map[style] || 'Neutraal';
},
rankedRows() {
const rows = this.visualRows().slice();
rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0));
return rows;
},
cardRows() {
return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim());
},
async setCockpitStyle(style) {
const p = this.selectedProject;
if (!p) return;
if (!p.db_id) {
p.row_style = style;
if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info');
return;
}
try {
await fetch('/api/revenue-cockpit/projects/' + p.db_id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_style: style }),
});
p.row_style = style;
const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p));
if (pi >= 0) this.projects[pi].row_style = style;
const di = this.dbProjects.findIndex((x) => x.id === p.db_id);
if (di >= 0) this.dbProjects[di].row_style = style;
if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success');
} catch (e) {
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
}
},
styleColor(style) {
const map = {
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
+13 -1
View File
@@ -33,7 +33,7 @@ window.VoiceLive = (function () {
busy: false,
conversationMode: true,
handsFreeMode: false,
speakReply: true,
speakReply: false,
useBrowserPreview: !!SpeechRecognition,
pipeline: [],
feed: [],
@@ -162,6 +162,18 @@ window.VoiceLive = (function () {
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL';
var voices = window.speechSynthesis.getVoices() || [];
var voice = null;
for (var vi = 0; vi < voices.length; vi++) {
var hay = ((voices[vi].name || '') + ' ' + (voices[vi].lang || ''));
if (/maarten/i.test(hay)) { voice = voices[vi]; break; }
}
if (!voice) {
for (var vj = 0; vj < voices.length; vj++) {
if (/^nl/i.test(voices[vj].lang || '')) { voice = voices[vj]; break; }
}
}
if (voice) u.voice = voice;
u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u);
} else {
+38 -7
View File
@@ -1,26 +1,57 @@
{
"name": "Cucina · Foodlinkk Command Center",
"id": "/",
"name": "Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
"start_url": "/",
"description": "CEO dashboard · Revenue Cockpit · CRM · Retail · Cucina & Foodlinkk",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "fullscreen", "browser"],
"orientation": "any",
"background_color": "#0a0e14",
"theme_color": "#0a0e14",
"lang": "nl",
"dir": "ltr",
"categories": ["business", "productivity", "finance"],
"prefer_related_applications": false,
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
],
"shortcuts": [
{
"name": "Overzicht",
"short_name": "Home",
"url": "/?source=pwa",
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
"name": "Revenue Cockpit",
"short_name": "Revenue",
"url": "/revenue-cockpit?source=pwa",
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
}
]
}
+58 -7
View File
@@ -1,18 +1,69 @@
/* PWA cache disabled — clears legacy caches and unregisters itself */
/* Foodlinkk PWA service worker v6 */
const CACHE = 'foodlinkk-shell-v6';
const PRECACHE = [
'/',
'/static/manifest.json',
'/static/css/mobile.css?v=6',
'/static/css/app-sidebar-neo.css?v=12',
'/static/css/palantir-theme.css?v=4',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/icons/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE).catch(() => undefined))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => self.clients.claim())
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
/* Do not intercept fetches — always use network */
self.addEventListener('fetch', () => {});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// API / live data: always network
if (url.pathname.startsWith('/api') || url.pathname.includes('/ws')) return;
// Navigations: network-first, fallback to cache
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy)).catch(() => undefined);
return res;
})
.catch(() => caches.match('/') || caches.match(req))
);
return;
}
// Static assets: stale-while-revalidate
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(req).then((cached) => {
const net = fetch(req)
.then((res) => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => undefined);
}
return res;
})
.catch(() => cached);
return cached || net;
})
);
}
});
+69
View File
@@ -0,0 +1,69 @@
/* Foodlinkk PWA service worker v6 */
const CACHE = 'foodlinkk-shell-v6';
const PRECACHE = [
'/',
'/static/manifest.json',
'/static/css/mobile.css?v=6',
'/static/css/app-sidebar-neo.css?v=12',
'/static/css/palantir-theme.css?v=4',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/icons/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE).catch(() => undefined))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// API / live data: always network
if (url.pathname.startsWith('/api') || url.pathname.includes('/ws')) return;
// Navigations: network-first, fallback to cache
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy)).catch(() => undefined);
return res;
})
.catch(() => caches.match('/') || caches.match(req))
);
return;
}
// Static assets: stale-while-revalidate
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(req).then((cached) => {
const net = fetch(req)
.then((res) => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => undefined);
}
return res;
})
.catch(() => cached);
return cached || net;
})
);
}
});
+386 -24
View File
@@ -4,18 +4,24 @@
<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?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
<script src="/static/js/agent-avatars.js?v=3"></script>
<link rel="stylesheet" href="/static/css/agent-characters.css?v=8" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=40" />
<script src="/static/js/agent-avatars.js?v=5"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header">
<div class="hm-neo" :class="{ 'tab-warroom-active': tab==='warroom' }" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header" x-show="tab!=='warroom'" x-cloak>
<h1>Agents · Soul Registry</h1>
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<header class="hm-neo-header page-header wr-page-header" x-show="tab==='warroom'" x-cloak>
<h1>War Room</h1>
<p class="hm-sub">Teamchat · @mentions · 1:1 DM · pop-out voor tweede scherm</p>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
<div id="viz-toolbar" class="viz-toolbar" x-show="tab!=='warroom'" x-cloak></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem"
x-show="tab!=='warroom'" x-cloak>
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
@@ -26,17 +32,20 @@
<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==='warroom'}" @click="setTab('warroom', $el)" data-tab="warroom">
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z"/><path d="m9 12 2 2 4-4"/></svg></span><span>War Room</span>
</button>
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
<span class="vtab-icon">🧬</span><span>Souls</span>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></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>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4"/><path d="m6.7 17.4 3.6-2.4"/><path d="m17.3 17.4-3.6-2.4"/><circle cx="12" cy="12" r="2"/></svg></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>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m7 8 3 3-3 3"/><path d="M13 14h3"/></svg></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-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg></span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
</nav>
@@ -46,18 +55,21 @@
<div class="hm-neo-kpi-row" style="grid-template-columns:repeat(4,1fr)">
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</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-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner">⚡</div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</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-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</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-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Aan het werk</div><div class="hm-neo-kpi-sub" x-text="idleCount + ' idle · ' + offlineCount + ' offline'">live status</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</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-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">Co-CEO · takenverdeler</div></div></div><div class="hm-neo-kpi-value" style="font-size:1.1rem">Co-CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</div>
<div class="agents-neo-grid">
<template x-for="s in souls" :key="s.agent_key">
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key, 'is-working': isWorking(s) && !isBusy(s), 'is-busy': isBusy(s) }" @click="select(s.agent_key)">
<div class="agent-char-wrap" :class="s.agent_key"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<strong x-text="s.display_name"></strong>
<strong>
<span x-text="s.display_name"></span>
<span class="agent-activity-eq" x-show="isWorking(s) || isBusy(s)" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
</strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-status-pill" :class="statusClass(s)">
<span class="dot"></span>
@@ -259,10 +271,319 @@
<p x-show="!items.length" class="muted">Geen approval requests.</p>
</div>
</section>
<section x-show="tab==='warroom'" x-cloak>
<div x-data="warRoomHub()" x-init="init()" class="wr-hub-root">
<div class="panel" style="padding:0.75rem">
<div class="wr-layout" :class="{'roster-collapsed': rosterCollapsed}">
<div class="wr-mobile-bar">
<button type="button" class="btn btn-sm" @click="rosterCollapsed = !rosterCollapsed"
x-text="rosterCollapsed ? '👥 Agents' : '▾ Agents'"></button>
<button type="button" class="btn btn-sm wr-a2a-btn"
:class="autoDebate ? 'is-on' : 'is-off'"
@click="toggleA2A()"
x-text="autoDebate ? 'A2A · aan' : 'A2A · uit'"></button>
</div>
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<span class="wr-chan-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z"/><path d="m9 12 2 2 4-4"/></svg></span><strong>#war-room</strong>
</button>
<h3 style="margin-top:0.85rem">Agents</h3>
<template x-for="s in souls" :key="'wr-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="selectAgent(s.agent_key)"
@dblclick.prevent="openDm(s.agent_key)"
title="Klik = @tag in War Room · dubbelklik = DM">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<div class="wr-agent-meta">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</div>
</button>
</template>
</aside>
<div class="wr-main">
<div class="wr-head">
<div class="wr-head-title">
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
<small class="wr-msg-count muted" x-show="messages.length"
x-text="(screenCleared ? '0' : visibleMessages().length) + ' / ' + messages.length + ' berichten'"></small>
</div>
<div class="wr-head-actions">
<button type="button" class="btn btn-sm wr-tool-btn" :class="{active: searchOpen}" @click="toggleSearch()" title="Zoeken in chat">🔍 Zoek</button>
<div class="wr-tools-wrap">
<button type="button" class="btn btn-sm wr-tool-btn" @click="toolsOpen=!toolsOpen" title="Meer acties">⋯</button>
<div class="wr-tools-menu" x-show="toolsOpen" @click.outside="toolsOpen=false" x-cloak>
<button type="button" @click="downloadChat('txt')">⬇ Download .txt</button>
<button type="button" @click="downloadChat('md')">⬇ Download .md</button>
<button type="button" @click="downloadChat('json')">⬇ Download .json</button>
<hr />
<button type="button" @click="clearScreen(); toolsOpen=false">🧹 Scherm legen</button>
<button type="button" x-show="screenCleared" @click="restoreScreen(); toolsOpen=false">↩ Scherm herstellen</button>
<button type="button" class="wr-danger" @click="clearChatHistory(); toolsOpen=false">🗑 Geschiedenis wissen</button>
</div>
</div>
<button type="button" class="btn btn-sm wr-a2a-btn"
:class="autoDebate ? 'is-on' : 'is-off'"
@click="toggleA2A()"
:title="autoDebate ? 'A2A staat aan — klik om uit te zetten' : 'A2A staat uit — klik om aan te zetten'">
<span x-text="autoDebate ? 'A2A · aan' : 'A2A · uit'"></span>
</button>
<button type="button" class="btn btn-sm wr-ceo-btn" @click="openCeoInbox()" title="Direct aan jou (@aissa)">
📬 CEO-inbox
<span class="wr-ceo-badge" x-show="ceoUnread > 0" x-text="ceoUnread"></span>
</button>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" title="Spraakmeeting met agents">🎙️ Voice</button>
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">↗</button>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy" title="Vernieuwen">↻</button>
</div>
</div>
<div class="wr-searchbar" x-show="searchOpen" x-cloak>
<input type="search" class="form-input" x-ref="wrSearch" x-model="chatSearch"
placeholder="Zoek in berichten, @agent, tekst…" />
<button type="button" class="btn btn-sm" @click="chatSearch=''" x-show="chatSearch">Wis</button>
<button type="button" class="btn btn-sm" @click="toggleSearch()">Sluit</button>
</div>
<div class="wr-log-wrap"><div class="wr-log" x-ref="wrLog" @scroll.passive="onLogScroll()">
<p class="wr-empty" x-show="screenCleared && !typing">
Scherm is geleegd.
<button type="button" class="btn btn-sm" @click="restoreScreen()">Herstellen</button>
</p>
<p class="wr-empty" x-show="!screenCleared && !visibleMessages().length && !typing && messages.length">Geen treffers voor deze zoekopdracht.</p>
<p class="wr-empty" x-show="!screenCleared && !messages.length && !typing">Nog geen berichten — tag agents met @marketing of deel een bestand.</p>
<template x-for="(m, mi) in visibleMessages()" :key="'msg-'+m.id">
<div class="wr-msg-wrap" :data-msg-id="m.id">
<div class="wr-day-sep" x-show="showDaySep(mi)">
<span x-text="formatMsgDate(m)"></span>
</div>
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system', 'is-ceo-direct': isCeoDirect(m), 'is-reply-target': replyHighlightId == m.id}">
<div class="wr-avatar" x-show="m.sender_type!=='system'">
<template x-if="m.sender_type==='agent'">
<div class="agent-char-wrap"
:data-agent-avatar="m.agent_key"
data-agent-color="#1e3a5f"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
</template>
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
</template>
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
</template>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span class="wr-who" x-text="labelForMessage(m)"></span>
<span class="wr-handle" x-show="m.agent_key" x-text="'@'+m.agent_key"></span>
<time class="wr-time" x-text="formatMsgTime(m)" :title="formatMsgDate(m) + ' ' + formatMsgTime(m)"></time>
<button type="button" class="wr-msg-reply" title="Beantwoorden"
@click.stop="setReply(m)">↩ Reply</button>
<button type="button" class="wr-msg-del" title="Bericht verwijderen"
@click.stop.prevent="deleteMessage(m)">🗑</button>
</div>
<div class="wr-reply-quote" x-show="replyPreview(m)" x-cloak>
<span class="wr-reply-quote-who" x-text="'↩ ' + ((replyPreview(m)||{}).agent_key ? ('@'+(replyPreview(m).agent_key)) : ((replyPreview(m)||{}).username || 'bericht'))"></span>
<span class="wr-reply-quote-text" x-text="(replyPreview(m)||{}).excerpt"></span>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<div class="wr-attach-list" x-show="attachmentsOf(m).length">
<template x-for="(a, ai) in attachmentsOf(m)" :key="m.id+'-a-'+ai">
<a class="wr-attach-card" :href="a.url" target="_blank" rel="noopener">
<img x-show="isImage(a)" :src="a.url" :alt="a.name" />
<span x-text="a.name"></span>
</a>
</template>
</div>
</div>
</div>
</div>
</template>
<div class="wr-bubble is-agent is-typing" x-show="typing" x-cloak>
<div class="wr-avatar">
<div class="agent-char-wrap"
:data-agent-avatar="typing && typing.agent_key"
x-effect="if (typing && window.AgentAvatars) AgentAvatars.paint($el, typing.agent_key)"></div>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span x-text="(typing && typing.display_name) || 'Agent'"></span>
<span> · typt</span>
</div>
<div class="wr-bubble-text wr-typing-cursor" x-text="(typing && typing.text) || '…'"></div>
</div>
</div>
</div>
<button type="button" class="wr-jump-latest" x-show="showJumpLatest" x-cloak
@click="jumpToLatest()" title="Naar nieuwste berichten">
↓ Nieuwste
</button>
</div>
<div class="wr-composer" @click="focusComposer()">
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
<label class="btn btn-sm" style="cursor:pointer;margin:0">
📎
<input type="file" hidden multiple @change="onPickFiles($event)"
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.xlsx,.xls,.doc,.docx,.zip,.json,.md,image/*" />
</label>
<div class="wr-emoji-pop" x-show="emojiOpen" @click.outside="emojiOpen=false" x-cloak>
<template x-for="em in emojis" :key="em">
<button type="button" @click="insertEmoji(em)" x-text="em"></button>
</template>
</div>
</div>
<div class="wr-pending" x-show="pendingFiles.length">
<template x-for="(f, fi) in pendingFiles" :key="f.url">
<span class="wr-pending-chip">
<span x-text="f.name"></span>
<button type="button" @click="removePending(fi)" title="Verwijderen">×</button>
</span>
</template>
</div>
<div class="wr-reply-bar" x-show="replyTo" x-cloak>
<div class="wr-reply-bar-text">
<strong x-text="'↩ Reply aan ' + (replyTo && (replyTo.agent_key ? ('@'+replyTo.agent_key) : (replyTo.label || 'bericht')))"></strong>
<span x-text="replyTo && replyTo.excerpt"></span>
</div>
<button type="button" class="btn btn-sm" @click="clearReply()" title="Annuleer reply">×</button>
</div>
<div class="wr-composer-wrap">
<div class="wr-mention-pop" x-show="mentionOpen && mentionSuggestions().length" x-cloak>
<template x-for="(s, si) in mentionSuggestions()" :key="'m-'+s.agent_key">
<button type="button" class="wr-mention-item"
:class="{active: si===mentionIndex}"
@mousedown.prevent="pickMention(s)">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</button>
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)" @keydown.enter.prevent="onEnter($event)"
placeholder="Typ hier… bv. @herman of @retail"
></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend() || _sendLock">Stuur</button>
</div>
<!-- CEO inbox (@aissa) -->
<div class="wr-ceo-overlay" x-show="ceoInboxOpen" x-cloak @keydown.escape.window="closeCeoInbox()">
<div class="wr-ceo-panel" @click.stop>
<div class="wr-ceo-head">
<div>
<h3>📬 CEO-inbox · @aissa</h3>
<small class="muted">Direct van agents aan jou (@aissa) — ook ongelezen van toen je offline was</small>
</div>
<div class="wr-ceo-head-actions">
<button type="button" class="btn btn-sm" @click="markAllCeoRead()" :disabled="!ceoUnread">Alles gelezen</button>
<button type="button" class="btn btn-sm" @click="loadCeoInbox()">↻</button>
<button type="button" class="btn btn-sm" @click="closeCeoInbox()">Sluit</button>
</div>
</div>
<div class="wr-ceo-list">
<p class="wr-empty" x-show="!(ceoInbox||[]).length">Nog geen directe berichten. Agents gebruiken @aissa als ze jou echt nodig hebben.</p>
<template x-for="item in ceoInbox" :key="'ceo-'+item.id">
<article class="wr-ceo-item" :class="{ unread: !item.read_at }">
<div class="wr-ceo-item-meta">
<strong x-text="item.agent_display_name || ('@'+(item.agent_key||'agent'))"></strong>
<span x-text="'@'+(item.agent_key||'')"></span>
<time x-text="formatMsgTime({created_at: item.created_at})"></time>
<span class="wr-ceo-unread-dot" x-show="!item.read_at">nieuw</span>
</div>
<div class="wr-ceo-item-body" x-text="item.content"></div>
<div class="wr-ceo-item-actions">
<button type="button" class="btn btn-sm" x-show="!item.read_at" @click="markCeoRead(item.id)">Markeer gelezen</button>
</div>
</article>
</template>
</div>
</div>
</div>
<template x-if="voiceRoomOpen">
<div class="wr-voice-overlay is-open" @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
<h3>🎙️ Foodlinkk Voice Room</h3>
<small class="muted">Jij + agents in één gesprek (spraak)</small>
</div>
<div class="wr-voice-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.25rem;margin:0;cursor:pointer">
<input type="checkbox" x-model="speakReplies" style="margin:0" /> 🔊 Voorlezen
</label>
<button type="button" class="btn btn-sm btn-pulse" @click="closeVoiceRoom()">Sluiten</button>
</div>
</div>
<div class="wr-voice-status" x-text="status || (voiceRecording ? 'Luisteren…' : (voiceSpeakingKey ? (voiceSpeakingKey + ' spreekt…') : 'Houd de microfoon in om te praten'))"></div>
<div class="wr-voice-grid">
<div class="wr-voice-card is-human" :class="{'is-live': voiceRecording}">
<div class="wr-voice-avatar wr-voice-avatar-human" x-text="(me.username||'Jij').slice(0,2).toUpperCase()"></div>
<strong x-text="me.username || 'Jij'"></strong>
<small x-show="voiceRecording">🎤 aan het woord</small>
<small x-show="!voiceRecording" class="muted">mens</small>
</div>
<template x-for="s in voiceAgentSouls()" :key="'vr-'+s.agent_key">
<div class="wr-voice-card is-agent"
:class="{'is-live': voiceSpeakingKey===s.agent_key, 'is-off': !isVoiceAgent(s.agent_key)}"
@click="toggleVoiceAgent(s.agent_key)"
:title="isVoiceAgent(s.agent_key) ? 'Klik = uit meeting' : 'Klik = in meeting'">
<div class="wr-voice-avatar agent-char-wrap"
:data-agent-avatar="s.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key))"></div>
<strong x-text="s.display_name || s.agent_key"></strong>
<small x-show="voiceSpeakingKey===s.agent_key">🔊 spreekt</small>
<small x-show="voiceSpeakingKey!==s.agent_key" class="muted" x-text="isVoiceAgent(s.agent_key) ? ('@'+s.agent_key) : 'niet in meeting'"></small>
</div>
</template>
</div>
<p class="wr-voice-hint" x-show="voiceLastHeard">Laatst gehoord: <span x-text="voiceLastHeard"></span></p>
<div class="wr-voice-ptt">
<button type="button" class="wr-ptt-btn"
:class="{'is-recording': voiceRecording, 'is-busy': voiceBusy}"
@mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)"
@touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)"
@mouseleave="voiceRecording && stopVoiceTalk($event)"
:disabled="voiceBusy || busy">
<span x-show="!voiceRecording && !voiceBusy">🎤 Ingedrukt houden</span>
<span x-show="voiceRecording">● Luisteren… laat los</span>
<span x-show="voiceBusy && !voiceRecording">⏳ Bezig…</span>
</button>
</div>
<div class="wr-voice-foot">
<span class="muted">Klik agents om ze in/uit de meeting te zetten. Standaard praat @herman; 🔊 leest antwoorden voor.</span>
<a class="btn btn-sm" href="https://meet.ffmuc.net/FoodlinkkWR1" :href="callHref()" target="_blank" rel="noopener">Alleen-mensen video ↗</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="agent-events-backdrop" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<div class="agent-events-backdrop" :class="{ 'is-open': eventsOpen }" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<aside class="agent-events-drawer" :class="{ open: eventsOpen }" x-cloak>
<header class="agent-events-head">
<div>
@@ -301,6 +622,7 @@
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=40"></script>
<script>
function agentsSoulHub() {
return {
@@ -317,12 +639,20 @@ function agentsSoulHub() {
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; },
get activeCount() { return this.souls.filter(s => (s.events_6h||0) > 0).length; },
get idleCount() { return this.souls.filter(s => (s.events_6h||0) === 0 && (s.event_count||0) > 0).length; },
get offlineCount() { return this.souls.filter(s => (s.event_count||0) === 0).length; },
isBusy(s) {
const st = (s.current_status || '').toLowerCase();
return st === 'needs_approval' || st === 'pending' || (s.activity === 'busy') || ((s.events_6h||0) >= 5);
},
isWorking(s) { return (s.events_6h||0) > 0 || s.working === true; },
statusClass(s) {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval' || st === 'pending') return 'busy';
if (st === 'needs_approval' || st === 'pending' || this.isBusy(s)) return 'busy';
if (st === 'error' || st === 'rejected') return 'error';
if (st === 'completed' || (s.events_6h > 0)) return 'active';
if ((s.events_6h||0) > 0 || s.activity === 'active') return 'active';
if ((s.event_count||0) === 0) return 'offline';
return 'idle';
},
formatEventTime(ts) {
@@ -363,15 +693,24 @@ function agentsSoulHub() {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval') return 'Wacht op goedkeuring';
if (st === 'pending') return 'In wachtrij';
if (st === 'completed') return 'Klaar';
if (st === 'error') return 'Fout';
if (s.current_task) return 'Bezig';
return 'Standby';
if (this.isBusy(s)) return 'Bezig · live';
if ((s.events_6h||0) > 0) return 'Actief · ' + (s.events_6h||0) + '/6u';
if ((s.event_count||0) === 0) return 'Offline';
return 'Idle';
},
async init() {
this.syncMeshMode();
await this.load();
await this.loadPendingCount();
this._poll = setInterval(() => {
if (this.tab === 'warroom') {
this.loadPendingCount();
return;
}
this.load(true);
this.loadPendingCount();
}, 20000);
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
@@ -386,7 +725,7 @@ function agentsSoulHub() {
},
});
}
if (window.AgentsTerminals) AgentsTerminals.startLiveStream();
if (window.AgentsTerminals && this.tab !== 'warroom') AgentsTerminals.startLiveStream();
if (this.tab === 'mesh') this.mountMesh();
if (this.tab === 'terminals') this.startTerminals();
});
@@ -399,6 +738,7 @@ function agentsSoulHub() {
this.tabIndicatorH = btnRect.height || 40;
},
setTab(next, el) {
const prev = this.tab;
this.tab = next;
this.updateTabIndicator(el);
const url = new URL(window.location.href);
@@ -406,6 +746,15 @@ function agentsSoulHub() {
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
if (next === 'terminals') this.startTerminals();
if (next === 'warroom') {
if (window.AgentsTerminals && AgentsTerminals.stopLiveStream) AgentsTerminals.stopLiveStream();
} else if (prev === 'warroom') {
if (window.AgentsTerminals && AgentsTerminals.startLiveStream) AgentsTerminals.startLiveStream();
this.$nextTick(() => {
this.renderViz(window.__vizMode || 'neo-bars');
window.AgentAvatars && AgentAvatars.paintAll(this.$root);
});
}
if (next === 'approvals') this.loadPendingCount();
},
async loadPendingCount() {
@@ -496,12 +845,25 @@ function agentsSoulHub() {
}
});
},
async load() {
async load(quiet) {
const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || [];
const items = r.items || [];
// Avoid Alpine thrash: only replace souls when something meaningful changed
const sig = items.map(s => [s.agent_key, s.event_count, s.events_6h, s.current_status, s.activity, s.current_task].join(':')).join('|');
if (sig !== this._soulsSig) {
this._soulsSig = sig;
this.souls = items;
}
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
if (this.tab === 'warroom') return; // no chart / global avatar repaint on War Room
this.$nextTick(() => {
(this.souls || []).forEach((s) => {
const el = document.querySelector('.agent-grid [data-agent-avatar="' + s.agent_key + '"], .hm-neo [data-agent-avatar="' + s.agent_key + '"]');
if (el && window.AgentAvatars) AgentAvatars.paint(el, s.agent_key, s.avatar_color);
});
});
this.renderViz(window.__vizMode || 'neo-bars');
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
if (!quiet) this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
},
renderViz(mode) {
if (!window.VizEngine) return;
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="nl">
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>War Room · Foodlinkk</title>
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/flk-themes.css?v=1" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=31" />
<script defer src="/static/js/agents-warroom.js?v=31"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script>
</head>
<body class="wr-popup-body">
<div class="wr-popup-shell" x-data="warRoomPopup()" x-init="init()">
<div class="wr-layout">
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<span>⚔️</span><strong>#war-room</strong>
</button>
<h3 style="margin-top:0.85rem">Agents</h3>
<template x-for="s in souls" :key="'p-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="selectAgent(s.agent_key)"
@dblclick.prevent="openDm(s.agent_key)"
title="Klik = @tag · dubbelklik = DM">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<div class="wr-agent-meta">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</div>
</button>
</template>
</aside>
<div class="wr-main">
<div class="wr-head">
<div>
<h2 x-text="room ? (room.title || 'War Room') : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
</div>
<div class="wr-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;margin:0" title="Agents praten onderling door"><input type="checkbox" x-model="autoDebate" @change="autoDebate && scheduleContinue(1500)" style="margin:0" /> A2A</label>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<a class="btn btn-sm" href="/agents?tab=warroom" target="_blank">In cockpit</a>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length && !typing">Nog geen berichten.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
<div class="wr-avatar" x-show="m.sender_type!=='system'">
<template x-if="m.sender_type==='agent'">
<div class="agent-char-wrap"
:data-agent-avatar="m.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
</template>
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
</template>
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
</template>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span x-text="labelForMessage(m)"></span>
<button type="button" class="wr-msg-del" title="Verwijderen" @click.stop="deleteMessage(m)">🗑</button>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<div class="wr-attach-list" x-show="attachmentsOf(m).length">
<template x-for="(a, ai) in attachmentsOf(m)" :key="m.id+'-pa-'+ai">
<a class="wr-attach-card" :href="a.url" target="_blank" rel="noopener">
<img x-show="isImage(a)" :src="a.url" :alt="a.name" />
<span x-text="a.name"></span>
</a>
</template>
</div>
</div>
</div>
</template>
<div class="wr-bubble is-agent is-typing" x-show="typing" x-cloak>
<div class="wr-avatar">
<div class="agent-char-wrap"
:data-agent-avatar="typing && typing.agent_key"
x-effect="if (typing && window.AgentAvatars) AgentAvatars.paint($el, typing.agent_key)"></div>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta"><span x-text="(typing && typing.display_name) || 'Agent'"></span><span> · typt</span></div>
<div class="wr-bubble-text wr-typing-cursor" x-text="(typing && typing.text) || '…'"></div>
</div>
</div>
</div>
<div class="wr-composer" @click="focusComposer()">
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
<label class="btn btn-sm" style="cursor:pointer;margin:0">
📎
<input type="file" hidden multiple @change="onPickFiles($event)"
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.xlsx,.xls,.doc,.docx,.zip,.json,.md,image/*" />
</label>
<div class="wr-emoji-pop" x-show="emojiOpen" @click.outside="emojiOpen=false" x-cloak>
<template x-for="em in emojis" :key="em">
<button type="button" @click="insertEmoji(em)" x-text="em"></button>
</template>
</div>
</div>
<div class="wr-pending" x-show="pendingFiles.length">
<template x-for="(f, fi) in pendingFiles" :key="f.url">
<span class="wr-pending-chip">
<span x-text="f.name"></span>
<button type="button" @click="removePending(fi)">×</button>
</span>
</template>
</div>
<div class="wr-composer-wrap">
<div class="wr-mention-pop" x-show="mentionOpen && mentionSuggestions().length" x-cloak>
<template x-for="(s, si) in mentionSuggestions()" :key="'pm-'+s.agent_key">
<button type="button" class="wr-mention-item"
:class="{active: si===mentionIndex}"
@mousedown.prevent="pickMention(s)">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</button>
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)" @keydown.enter.prevent="onEnter($event)"
placeholder="Typ hier… bv. @herman"
></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend() || _sendLock">Stuur</button>
</div>
<template x-if="voiceRoomOpen">
<div class="wr-voice-overlay is-open" @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
<h3>🎙️ Foodlinkk Voice Room</h3>
<small class="muted">Jij + agents in één gesprek (spraak)</small>
</div>
<div class="wr-voice-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.25rem;margin:0;cursor:pointer">
<input type="checkbox" x-model="speakReplies" style="margin:0" /> 🔊 Voorlezen
</label>
<button type="button" class="btn btn-sm btn-pulse" @click="closeVoiceRoom()">Sluiten</button>
</div>
</div>
<div class="wr-voice-status" x-text="status || (voiceRecording ? 'Luisteren…' : (voiceSpeakingKey ? (voiceSpeakingKey + ' spreekt…') : 'Houd de microfoon in om te praten'))"></div>
<div class="wr-voice-grid">
<div class="wr-voice-card is-human" :class="{'is-live': voiceRecording}">
<div class="wr-voice-avatar wr-voice-avatar-human" x-text="(me.username||'Jij').slice(0,2).toUpperCase()"></div>
<strong x-text="me.username || 'Jij'"></strong>
<small x-show="voiceRecording">🎤 aan het woord</small>
<small x-show="!voiceRecording" class="muted">mens</small>
</div>
<template x-for="s in voiceAgentSouls()" :key="'vr-'+s.agent_key">
<div class="wr-voice-card is-agent"
:class="{'is-live': voiceSpeakingKey===s.agent_key, 'is-off': !isVoiceAgent(s.agent_key)}"
@click="toggleVoiceAgent(s.agent_key)"
:title="isVoiceAgent(s.agent_key) ? 'Klik = uit meeting' : 'Klik = in meeting'">
<div class="wr-voice-avatar agent-char-wrap"
:data-agent-avatar="s.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key))"></div>
<strong x-text="s.display_name || s.agent_key"></strong>
<small x-show="voiceSpeakingKey===s.agent_key">🔊 spreekt</small>
<small x-show="voiceSpeakingKey!==s.agent_key" class="muted" x-text="isVoiceAgent(s.agent_key) ? ('@'+s.agent_key) : 'niet in meeting'"></small>
</div>
</template>
</div>
<p class="wr-voice-hint" x-show="voiceLastHeard">Laatst gehoord: <span x-text="voiceLastHeard"></span></p>
<div class="wr-voice-ptt">
<button type="button" class="wr-ptt-btn"
:class="{'is-recording': voiceRecording, 'is-busy': voiceBusy}"
@mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)"
@touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)"
@mouseleave="voiceRecording && stopVoiceTalk($event)"
:disabled="voiceBusy || busy">
<span x-show="!voiceRecording && !voiceBusy">🎤 Ingedrukt houden</span>
<span x-show="voiceRecording">● Luisteren… laat los</span>
<span x-show="voiceBusy && !voiceRecording">⏳ Bezig…</span>
</button>
</div>
<div class="wr-voice-foot">
<span class="muted">Klik agents om ze in/uit de meeting te zetten. Standaard praat @herman; 🔊 leest antwoorden voor.</span>
<a class="btn btn-sm" href="https://meet.ffmuc.net/FoodlinkkWR1" :href="callHref()" target="_blank" rel="noopener">Alleen-mensen video ↗</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<script src="/static/js/cockpit.js?v=3"></script>
<script src="/static/js/agent-avatars.js?v=4"></script>
<!-- agents-warroom loaded in head (before alpine) -->
</body>
</html>
+237 -62
View File
@@ -1,75 +1,250 @@
{% extends "base.html" %}
{% block title %}Intelligence · Analytics{% endblock %}
{% block extra_head %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
<link rel="stylesheet" href="/static/css/analytics.css?v=20" />
{% endblock %}
{% block content %}
<div class="analytics-shell" x-data="analyticsHub()" x-init="init()">
<header class="page-header">
<div>
<h1>Analytics Command Center <span class="live-badge" x-text="liveLabel">Live</span></h1>
<p class="subtitle">Volledige database analyse · filter · charts · realtime</p>
<small class="muted" x-text="'Laatst: '+updatedAt"></small>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Ververs</button>
<a class="btn btn-sm" href="/">Herman →</a>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
</div>
</header>
<aside class="analytics-filters panel">
<h3>Filters</h3>
<label>Keten<select x-model="f.chain" @change="refresh()"><option value="">Alle</option>
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
</select></label>
<label>Provincie<select x-model="f.province" @change="refresh()"><option value="">Alle</option>
<template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
</select></label>
<label>Deal stage<select x-model="f.stage" @change="refresh()"><option value="">Alle</option>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template>
</select></label>
<label>Agent<select x-model="f.agent" @change="refresh()"><option value="">Alle</option>
<template x-for="a in meta.agents||[]" :key="a.agent_name"><option :value="a.agent_name" x-text="a.agent_name"></option></template>
</select></label>
<label>Periode (dagen)<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh()"></label>
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset</button>
</aside>
<div class="analytics-main">
<div class="kpi-row analytics-kpis" id="analytics-kpis"></div>
<div class="analytics-charts">
<div class="panel chart-panel"><h4>Pipeline per stage</h4><canvas id="chart-deals"></canvas></div>
<div class="panel chart-panel"><h4>Klanten per stage</h4><canvas id="chart-clients"></canvas></div>
<div class="panel chart-panel"><h4>Supermarkten per keten</h4><canvas id="chart-chains"></canvas></div>
<div class="panel chart-panel"><h4>Provincie verdeling</h4><canvas id="chart-provinces"></canvas></div>
<div class="panel chart-panel"><h4>CRM partnerships</h4><canvas id="chart-partnerships"></canvas></div>
<div class="panel chart-panel"><h4>Agent activiteit</h4><canvas id="chart-agents"></canvas></div>
<div class="panel chart-panel"><h4>Events timeline</h4><canvas id="chart-timeline"></canvas></div>
<div class="panel chart-panel"><h4>RSS per categorie</h4><canvas id="chart-rss"></canvas></div>
<div class="panel chart-panel"><h4>Groothandels provincie</h4><canvas id="chart-wholesale"></canvas></div>
<div class="panel chart-panel"><h4>NAS sentiment</h4><canvas id="chart-sentiment"></canvas></div>
<div class="panel chart-panel"><h4>Top woorden NAS</h4><canvas id="chart-words"></canvas></div>
<div class="panel chart-panel"><h4>Halal kansen top 10</h4><canvas id="chart-opportunities"></canvas></div>
<div class="panel chart-panel"><h4>Actieve promo folders</h4><canvas id="chart-promo"></canvas></div>
<div class="panel chart-panel"><h4>Milestones status</h4><canvas id="chart-milestones"></canvas></div>
<div class="ax-shell" x-data="analyticsHub()" x-init="init()">
<!-- Hero -->
<section class="ax-hero">
<div class="ax-hero-glow" aria-hidden="true"></div>
<div class="ax-hero-grid">
<div class="ax-hero-copy">
<p class="ax-eyebrow">Foodlinkk · Intelligence</p>
<h1>Speelveld<span class="ax-live" x-text="liveLabel">Live</span></h1>
<p class="ax-lead" x-text="pulse.sub || 'Wat goed gaat en waar de kansen liggen.'"></p>
<p class="ax-goal" x-show="goals.tagline || goals.vision_text">
<span x-text="goals.tagline || ''"></span>
<span x-show="goals.vision_text" x-text="' · ' + shortGoal(goals.vision_text)"></span>
</p>
</div>
<div class="ax-pulse-orb" :class="'tone-' + (pulse.tone || 'mid')">
<svg viewBox="0 0 120 120" class="ax-ring">
<circle cx="60" cy="60" r="52" class="ax-ring-track"></circle>
<circle cx="60" cy="60" r="52" class="ax-ring-value"
:style="'stroke-dasharray:' + ringDash(pulse.score) + ' 999'"></circle>
</svg>
<div class="ax-pulse-core">
<strong class="ax-mono" x-text="pulse.score ?? '—'"></strong>
<span>pulse</span>
</div>
<p class="ax-pulse-label" x-text="pulse.label || ''"></p>
</div>
<div class="ax-hero-actions">
<button type="button" class="ax-btn ax-btn-primary" @click="refresh()" :disabled="loading">↻ Ververs</button>
<button type="button" class="ax-btn" @click="showFilters = !showFilters" x-text="showFilters ? 'Filters verbergen' : 'Filters'"></button>
<a class="ax-btn" href="/retail">Retail 360</a>
<a class="ax-btn" href="/revenue-cockpit">Revenue</a>
<small class="ax-updated" x-text="'Sync ' + updatedAt"></small>
</div>
</div>
<div class="grid-2">
<section class="panel"><h2>Recente deals</h2>
<table class="data-table"><thead><tr><th>Titel</th><th>Stage</th><th>Waarde</th></tr></thead>
<tbody id="analytics-deals-table"></tbody></table>
</section>
<section class="panel"><h2>Agent events</h2>
<table class="data-table"><thead><tr><th>Agent</th><th>Event</th><th>Tijd</th></tr></thead>
<tbody id="analytics-events-table"></tbody></table>
</section>
<div class="ax-filters" x-show="showFilters" x-cloak>
<label>Keten
<select x-model="f.chain" @change="refresh(false)"><option value="">Alle</option>
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
</select>
</label>
<label>Provincie
<select x-model="f.province" @change="refresh(false)"><option value="">Alle</option>
<template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
</select>
</label>
<label>Deal stage
<select x-model="f.stage" @change="refresh(false)"><option value="">Alle</option>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template>
</select>
</label>
<label>Periode
<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh(false)">
</label>
<button type="button" class="ax-btn" @click="resetFilters()">Reset</button>
</div>
</div>
</section>
<!-- Signal strip -->
<section class="ax-signals">
<template x-for="s in signals" :key="s.key">
<article class="ax-signal" :class="s.tone || ''">
<span class="ax-signal-label" x-text="s.label"></span>
<strong class="ax-mono" x-text="s.value"></strong>
<em x-text="s.hint"></em>
</article>
</template>
</section>
<!-- Wins vs Opportunities -->
<section class="ax-split">
<div class="ax-col ax-col-win">
<header class="ax-col-head">
<h2>Wat goed gaat</h2>
<span x-text="(pulse.wins || []).length + ' signalen'"></span>
</header>
<template x-for="w in (pulse.wins || [])" :key="w.id">
<a class="ax-insight win" :href="w.href || '#'">
<div class="ax-insight-top">
<strong x-text="w.title"></strong>
<span class="ax-score ax-mono" x-text="w.score"></span>
</div>
<p x-text="w.body"></p>
<div class="ax-insight-foot">
<span x-text="w.metric"></span>
<span class="ax-bar"><i :style="'width:' + Math.min(100, w.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.wins || []).length">Nog geen wins berekend.</p>
</div>
<div class="ax-col ax-col-opp">
<header class="ax-col-head">
<h2>Kansen</h2>
<span x-text="(pulse.opportunities || []).length + ' open'"></span>
</header>
<template x-for="o in (pulse.opportunities || [])" :key="o.id">
<a class="ax-insight opp" :href="o.href || '#'">
<div class="ax-insight-top">
<strong x-text="o.title"></strong>
<span class="ax-score ax-mono" x-text="o.score"></span>
</div>
<p x-text="o.body"></p>
<div class="ax-insight-foot">
<span><span class="ax-tag" x-show="o.tag" x-text="o.tag"></span> <span x-text="o.metric"></span></span>
<span class="ax-bar opp"><i :style="'width:' + Math.min(100, o.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.opportunities || []).length">Geen kansen gevonden.</p>
</div>
</section>
<!-- Attention + Hermes findings -->
<section class="ax-band">
<div class="ax-attention" x-show="(pulse.attention || []).length">
<h3>Aandacht</h3>
<div class="ax-att-row">
<template x-for="a in (pulse.attention || [])" :key="a.id">
<a class="ax-att-chip" :href="a.href || '#'">
<strong x-text="a.title"></strong>
<span x-text="a.body"></span>
</a>
</template>
</div>
</div>
<div class="ax-hermes">
<header class="ax-col-head">
<div>
<h2>Hermes bevindingen</h2>
<p class="ax-muted">Diepere analyse die Hermes hier later aanvult via <code>/analytics/api/findings</code></p>
</div>
<span class="ax-pill" x-text="(findings || []).length ? ((findings || []).length + ' findings') : 'wacht op Hermes'"></span>
</header>
<div class="ax-findings-grid" x-show="(findings || []).length">
<template x-for="f in findings" :key="f.id">
<article class="ax-finding" :class="'kind-' + (f.kind || 'insight')">
<div class="ax-finding-top">
<span class="ax-tag" x-text="f.kind || 'insight'"></span>
<time x-text="formatTime(f.created_at)"></time>
</div>
<h4 x-text="f.title"></h4>
<p x-text="f.body"></p>
<div class="ax-finding-foot">
<span class="ax-mono" x-show="f.score != null" x-text="'score ' + f.score"></span>
<span x-text="f.source || 'hermes'"></span>
</div>
</article>
</template>
</div>
<div class="ax-findings-empty" x-show="!(findings || []).length">
<div class="ax-findings-placeholder">
<strong>Nog geen Hermes-findings</strong>
<p>Automatische wins & kansen staan hierboven. Zodra Hermes diepere markt- of concurrentie-inzichten heeft, landen ze hier.</p>
</div>
</div>
</div>
</section>
<!-- Market radar -->
<section class="ax-radar">
<header class="ax-col-head">
<h2>Market radar</h2>
<span>live charts</span>
</header>
<div class="ax-charts">
<article class="ax-chart-card wide">
<h3>Agent pulse</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-timeline"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Partnership mix</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-partners"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Top halal-kansen</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-opps"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Supermarkten per keten</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-chains"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Provincie footprint</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-prov"></canvas></div>
</article>
</div>
</section>
<!-- Lists -->
<section class="ax-lists">
<article class="ax-list-card">
<h3>Halal opportunity board</h3>
<ul>
<template x-for="(o, idx) in (data.top_opportunities || []).slice(0,8)" :key="idx">
<li>
<span class="ax-rank ax-mono" x-text="idx + 1"></span>
<div>
<strong x-text="(o.chain || '') + ' · ' + (o.city || '')"></strong>
<div class="ax-mini-bar"><i :style="'width:' + Math.min(100, o.halal_opportunity_score || 0) + '%'"></i></div>
</div>
<em class="ax-mono" x-text="Math.round(o.halal_opportunity_score || 0)"></em>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Recente agent events</h3>
<ul class="ax-events">
<template x-for="e in (data.recent_events || []).slice(0,8)" :key="e.created_at + (e.title||'')">
<li>
<div>
<strong x-text="e.title || e.event_type"></strong>
<span x-text="e.agent_name"></span>
</div>
<time x-text="formatTime(e.created_at)"></time>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Coverage snapshot</h3>
<dl class="ax-dl">
<div><dt>Partnership dekking</dt><dd class="ax-mono" x-text="(pulse.cover_pct || 0) + '%'"></dd></div>
<div><dt>White space</dt><dd class="ax-mono" x-text="fmt(pulse.white_space)"></dd></div>
<div><dt>NAS docs</dt><dd class="ax-mono" x-text="fmt(kpis.nas_docs)"></dd></div>
<div><dt>SM contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_supermarket)"></dd></div>
<div><dt>GH contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_wholesaler)"></dd></div>
<div><dt>Pending approvals</dt><dd class="ax-mono" x-text="fmt(kpis.pending_approvals)"></dd></div>
</dl>
</article>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=2"></script>
<script src="/static/js/analytics.js?v=20"></script>
{% endblock %}
+407 -69
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="nl">
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);var s=localStorage.getItem('flk-sidebar-theme');if(s)document.documentElement.setAttribute('data-sidebar',s);var b=localStorage.getItem('flk-page-bg');if(b)document.documentElement.setAttribute('data-bg',b);var ss=localStorage.getItem('flk-sidebar-style');if(ss==='plain')document.documentElement.setAttribute('data-sidebar-style','plain');else document.documentElement.removeAttribute('data-sidebar-style');}catch(e){}})();</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
@@ -12,25 +13,36 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
<link rel="manifest" href="/static/manifest.json?v=2" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="manifest" href="/static/manifest.json?v=3" />
<meta name="application-name" content="Foodlinkk" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
<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="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" sizes="192x192" />
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=4" />
<link rel="stylesheet" href="/static/css/flk-themes.css?v=8" />
<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=9" />
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=22" />
<script defer src="/static/js/alpine.min.js?v=2"></script>
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=10" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
<script defer src="/static/js/herman-assistant.js?v=17"></script>
<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=8" />
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=13" />
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
<!-- mobile last: wins over sidebar/dashboard cascade -->
<link rel="stylesheet" href="/static/css/mobile.css?v=12" />
<link rel="stylesheet" href="/static/css/user-avatar.css?v=3" />
</head>
<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">
<aside class="app-sidebar" :style="'--sidebar-width:' + sidebarWidth + 'px'" @click="if($event.target.closest('a')) navOpen=false">
<div class="app-sidebar-resizer" title="Sleep om breder te maken"
@mousedown.prevent="startSidebarResize($event)"></div>
<div class="app-sidebar-brand">
<p class="brand-title">Cucina · Foodlinkk</p>
<p class="brand-name" x-text="brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'">Cucina B2C</p>
@@ -48,37 +60,36 @@
<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>
<a href="/export-intel" data-perm="export" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" data-perm="clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" data-perm="deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 6"/><path d="m3 13 1.68-1.68a2.41 2.41 0 0 1 3.41 0L12 16"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" data-perm="products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 7.7 4.7L18.7 7"/><path d="m7.5 4.3 9 5.2"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" data-perm="suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M17 18h1"/><path d="M12 18h1"/><path d="M7 18h1"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/reports" data-perm="reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></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>
<a href="/" data-perm="dashboard" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" data-perm="revenue" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" data-perm="retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" data-perm="marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 13v-2z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" data-perm="packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" data-perm="beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 21 14 3"/><path d="M20.5 21 10 3"/><path d="M15.5 21 12 15l-3.5 6"/><path d="M2 21h20"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" data-perm="documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" data-perm="projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" data-perm="hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" data-perm="analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.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>
<a href="/ops" data-perm="ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents?tab=warroom" data-perm="agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" data-perm="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" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" data-perm="studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" data-perm="voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" data-perm="settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
</div>
</div>
</aside>
@@ -98,25 +109,69 @@
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
{% set cu = request.state.user if request is defined else none %}
<div class="app-user-chip"{% if not cu %} x-show="authUser" x-cloak{% endif %}
x-data="userChip()" x-init="bootAvatar()">
<button type="button" class="user-avatar-btn" title="Profielfoto wijzigen" @click="pickAvatar()">
{% if cu and cu.avatar_url %}
<img class="user-avatar-img" src="{{ cu.avatar_url }}"
:src="photoUrl || '{{ cu.avatar_url }}'" alt="" width="36" height="36" />
{% else %}
<img class="user-avatar-img" x-show="photoUrl" :src="photoUrl" alt="" width="36" height="36" />
<span class="user-avatar-fallback" x-show="!photoUrl" x-text="initials">?</span>
{% endif %}
</button>
<input type="file" x-ref="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif"
class="user-avatar-input" style="display:none!important;position:absolute;width:0;height:0;opacity:0" tabindex="-1" aria-hidden="true" @change="onFile($event)" />
<div class="user-avatar-meta">
<span class="user-avatar-name" x-text="name">{% if cu %}{{ cu.display_name or cu.username }}{% endif %}</span>
<span class="user-avatar-role" x-text="role">{% if cu %}{{ cu.role }}{% endif %}</span>
</div>
<a class="btn btn-sm btn-secondary user-logout-btn" href="/logout">Uitloggen</a>
</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 class="pwa-install-bar" x-show="showInstall" x-cloak
:class="{ 'is-ios': isIos, 'is-android': isAndroid }"
style="display:none">
<p>Installeer Foodlinkk op je beginscherm</p>
<p class="pwa-install-hint is-ios-hint">iPhone/iPad (Safari): Deel ↗ → <strong>Zet op beginscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong> — geen certificaatmelding.</p>
<p class="pwa-install-hint is-android-hint">Android (Chrome): menu ⋮ → <strong>Toevoegen aan startscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong>.</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Uitleg</button>
<button type="button" class="btn btn-sm" @click="dismissPwa()">Later</button>
</div>
<!-- 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">
<!-- Herman chat — altijd vast onderin zichtbaar -->
<div id="ha-dock" class="ha-dock" x-data="hermanBrowserWidget()" x-init="initWidget()" :class="{ 'ha-dock-hidden': hidden }">
<button type="button" class="ha-fab"
:class="{ 'is-open': open, 'is-busy': busy, 'is-dragging': fabDragging, 'has-custom-pos': !!fabPos }"
:style="fabStyle()"
@pointerdown.prevent="onFabPointerDown($event)"
@pointermove="onFabPointerMove($event)"
@pointerup="onFabPointerUp($event)"
@pointercancel="onFabPointerUp($event)"
@click="onFabClick($event)"
@dblclick="onFabDblClick($event)"
title="Slepen om te verplaatsen · klik om te openen · dubbelklik = reset"
aria-label="Open Herman chat">
<span class="ha-fab-icon" aria-hidden="true">💬</span>
<span class="ha-fab-label" x-text="open ? 'Sluiten' : 'Herman'"></span>
</button>
<div class="ha-panel" :class="{ 'has-custom-pos': !!fabPos }" :style="panelStyle()" x-show="open" x-cloak x-transition.opacity.duration.150ms @keydown.escape.window="open = false">
<header class="ha-head">
<h2>Herman <span style="font-size:0.65rem;color:#a78bfa">browser</span></h2>
<div class="ha-head-title">
<h2>Herman</h2>
<p class="ha-conn" :class="{ 'is-ok': connOk, 'is-err': connOk === false }" x-text="connLabel"></p>
</div>
<div class="ha-head-actions">
<a href="/voice">Voice live</a>
<a x-show="!micSecureOk()" class="ha-text-btn" :href="httpsChatUrl()" target="_blank" rel="noopener" title="Audio/microfoon in HTTPS-tab">Audio</a>
<button type="button" class="ha-text-btn" @click="clearChat()" title="Nieuwe chat" :disabled="busy || (!turns.length && !manualText)">Wissen</button>
<button type="button" class="ha-text-btn" @click="openWarRoomPopup()" title="War Room op tweede scherm">War Room</button>
<a href="/agents?tab=warroom">Agents</a>
<a href="/herman">Volledig</a>
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
</div>
</header>
@@ -130,23 +185,39 @@
<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>
<div class="ha-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : (' · ' + ((t.meta && t.meta.agent_label) || '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>
<p x-show="!turns.length" class="page-tip" style="font-size:0.75rem;margin:0">Chat met Herman op de achtergrond — 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">
<div class="ha-toggles">
<label title="Alleen voorlezen als jij dit aanzet">
<input type="checkbox" x-model="speakReply" @change="persistPrefs()" />
<span>Voorlezen (Maarten)</span>
</label>
<label title="Na antwoord meteen weer luisteren">
<input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" />
<span>Hands-free praten</span>
</label>
</div>
<p class="ha-status" :class="{ 'is-error': status === 'error' }" x-show="statusDetail || busy" x-text="statusDetail || (busy ? 'Herman denkt…' : '')"></p>
<div class="ha-https-prompt" x-show="httpsAudioPrompt" x-cloak>
<span>Microfoon werkt via HTTPS</span>
<button type="button" class="btn btn-sm btn-pulse" @click="goHttpsAudio()">Doorgaan naar HTTPS →</button>
<button type="button" class="ha-text-btn" @click="httpsAudioPrompt = false" title="Sluiten">×</button>
</div>
<form class="ha-input-row" @submit.prevent="sendText()">
<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"
<input class="form-input" type="text" name="herman_message" autocomplete="off"
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>
<button type="submit" class="btn btn-sm btn-pulse" :disabled="busy || !manualText.trim()">→</button>
</form>
</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>
@@ -187,10 +258,8 @@
</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/cockpit.js?v=3"></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=3"></script>
@@ -216,34 +285,161 @@
/* legacy persona helper → brand */
window.FoodlinkkPersona = window.FoodlinkkBrand;
function userChip() {
return {
photoUrl: {% if cu and cu.avatar_url %}'{{ cu.avatar_url }}'{% else %}null{% endif %},
name: {% if cu %}'{{ (cu.display_name or cu.username)|e }}'{% else %}''{% endif %},
role: {% if cu %}'{{ cu.role }}'{% else %}''{% endif %},
initials: {% if cu %}'{{ ((cu.display_name or cu.username or '?')[0:1])|upper }}'{% else %}'?'{% endif %},
bootAvatar() {
// sync from parent shell when /api/auth/me returns
window.addEventListener('flk-auth-ready', (ev) => {
const u = ev.detail;
if (!u) return;
this.photoUrl = u.avatar_url || null;
this.name = u.display_name || u.username || '';
this.role = u.role || '';
const n = (this.name || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
this.initials = parts.length >= 2 ? (parts[0][0] + parts[1][0]).toUpperCase() : n.slice(0, 1).toUpperCase();
});
},
pickAvatar() { this.$refs.avatarFile && this.$refs.avatarFile.click(); },
async onFile(ev) {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
this.photoUrl = (data.avatar_url || (data.user && data.user.avatar_url)) + '?t=' + Date.now();
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: data.user }));
} catch (e) {
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
else alert(e.message || 'Upload mislukt');
}
}
};
}
function appShell() {
return {
locale: 'nl',
brand: 'cucina',
drawerOpen: false,
navOpen: false,
sidebarWidth: 280,
showInstall: false,
authUser: null,
authPages: [],
_deferredPrompt: null,
clockDate: '—',
clockTime: '—',
_offset: 0,
isIos: false,
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()));
}
this.isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// One-time migration off legacy no-op SW
if (!localStorage.getItem('flk-pwa-v6')) {
if (window.caches) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
localStorage.setItem('flk-cache-v3', '1');
localStorage.setItem('flk-pwa-v6', '1');
localStorage.setItem('flk-cache-v4', '1');
}
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initSidebarWidth();
this.initClock();
this.initPwa();
await this.initAuth();
},
async initAuth() {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (r.status === 401) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
if (!r.ok) return;
const data = await r.json();
this.authUser = Object.assign({}, data.user || null, { _avTick: Date.now() });
this.authPages = (data.user && data.user.permissions && data.user.permissions.pages) || [];
this.applyNavPermissions();
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: this.authUser }));
} catch (e) { /* ignore */ }
},
canPage(key) {
if (!this.authUser) return true;
if (this.authUser.role === 'admin') return true;
return this.authPages.indexOf(key) !== -1;
},
applyNavPermissions() {
if (!this.authUser || this.authUser.role === 'admin') return;
document.querySelectorAll('.app-sidebar a.nav-pill[data-perm]').forEach((a) => {
const key = a.getAttribute('data-perm');
a.style.display = this.canPage(key) ? '' : 'none';
});
},
avatarInitials() {
const u = this.authUser;
if (!u) return '?';
const n = (u.display_name || u.username || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return n.slice(0, 1).toUpperCase();
},
async uploadMyAvatar(ev) {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
this.authUser = Object.assign({}, data.user, { _avTick: Date.now() });
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
} catch (e) {
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
else alert(e.message || 'Upload mislukt');
}
},
initSidebarWidth() {
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
if (!isFinite(saved)) saved = 280;
this.sidebarWidth = Math.min(420, Math.max(220, saved));
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
},
startSidebarResize(e) {
var self = this;
var startX = e.clientX;
var startW = this.sidebarWidth;
var handle = e.currentTarget;
handle.classList.add('is-dragging');
document.body.classList.add('sidebar-resizing');
function onMove(ev) {
var next = Math.min(420, Math.max(220, startW + (ev.clientX - startX)));
self.sidebarWidth = next;
document.documentElement.style.setProperty('--sidebar-width', next + 'px');
}
function onUp() {
handle.classList.remove('is-dragging');
document.body.classList.remove('sidebar-resizing');
localStorage.setItem('flk-sidebar-width', String(self.sidebarWidth));
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
setBrand(b) {
if (b === this.brand) return;
@@ -276,32 +472,49 @@
tick();
setInterval(tick, 1000);
},
isAndroid: false,
initPwa() {
if (window.matchMedia('(display-mode: standalone)').matches) return;
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.showInstall = !localStorage.getItem('pwa-dismiss');
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
if (standalone) return;
this.isAndroid = /Android/i.test(navigator.userAgent);
// SW alleen proberen op secure context (https/localhost). Op http in LAN faalt dit stil — app blijft gewoon werken.
if (window.isSecureContext && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function (err) {
console.warn('SW register failed', err);
});
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this._deferredPrompt = e;
this.showInstall = true;
if (!localStorage.getItem('pwa-dismiss')) this.showInstall = true;
});
if (!localStorage.getItem('pwa-dismiss') && (this.isIos || this.isAndroid)) {
this.showInstall = true;
}
},
dismissPwa() {
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
},
async installPwa() {
if (this._deferredPrompt) {
this._deferredPrompt.prompt();
await this._deferredPrompt.userChoice;
this._deferredPrompt = null;
this.showInstall = false;
this.dismissPwa();
return;
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
if (this.isIos) {
alert('Open http://10.4.7.18:8610 in Safari\n\n1. Tik op Deel (↗)\n2. Kies «Zet op beginscherm»\n3. Bevestig met «Zet erop»\n\nGeen HTTPS nodig — geen certificaatmelding.');
} else {
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
alert('Open http://10.4.7.18:8610 in Chrome\n\n1. Tik op ⋮ (rechtsboven)\n2. «Toevoegen aan startscherm» of «App installeren»\n3. Bevestig\n\nGeen HTTPS nodig — geen certificaatmelding.');
}
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
this.dismissPwa();
}
};
}
@@ -316,6 +529,131 @@
}, 2500);
});
</script>
{% block scripts %}{% endblock %}
<script>
window.FlkThemes = {
list: [
{ id: 'ocean', name: 'Ocean Cyan', desc: 'Diep navy met fel cyaan' },
{ id: 'aurora', name: 'Aurora', desc: 'Blauw-violet met cyaan highlights' },
{ id: 'neon', name: 'Neon Tide', desc: 'Donker neon cyaan + magenta' },
{ id: 'glacier', name: 'Glacier', desc: 'IJsblauw en helder sky-cyan' },
{ id: 'cobalt', name: 'Cobalt Pulse', desc: 'Kobaltblauw met aqua en amber' },
{ id: 'reef', name: 'Reef', desc: 'Teal-diep met helder mint' },
{ id: 'plasma', name: 'Plasma', desc: 'Paars-nacht met cyaan vonken' },
{ id: 'lagoon', name: 'Lagoon', desc: 'Tropisch blauwgroen water' },
{ id: 'indigo', name: 'Indigo Night', desc: 'Indigo met lavendel accents' },
{ id: 'skyline', name: 'Skyline', desc: 'Stad-nacht blauw + sky' },
{ id: 'mintvolt', name: 'Mint Volt', desc: 'Donker groen met mint-energie' },
{ id: 'sapphire', name: 'Sapphire', desc: 'Klassiek saffierblauw' },
{ id: 'arctic', name: 'Arctic Glow', desc: 'Koud grijsblauw met ice-cyan' },
{ id: 'hyperion', name: 'Hyperion', desc: 'Cyaan met lime-flits' },
{ id: 'meridian', name: 'Meridian', desc: 'Violet-meridiaan met aqua' }
],
sidebars: [
{ id: 'aqua', name: 'Deep Aqua', desc: 'Diep aqua, strak en koel' },
{ id: 'azure', name: 'Royal Azure', desc: 'Diep koningsblauw' },
{ id: 'turquoise', name: 'Abyss Teal', desc: 'Diep teal, premium' },
{ id: 'cerulean', name: 'Ocean Depth', desc: 'Diep oceaanblauw' },
{ id: 'electric', name: 'Electric Cyan', desc: 'Donker elektrisch cyaan' },
{ id: 'violet', name: 'Indigo Night', desc: 'Diep indigo-violet (geen roze)' },
{ id: 'emerald', name: 'Jade Depth', desc: 'Diep jade smaragd' },
{ id: 'midnight', name: 'Midnight Navy', desc: 'Diep marinezwart-blauw' },
{ id: 'steel', name: 'Steel Blue', desc: 'Strak staalblauw' },
{ id: 'nordic', name: 'Nordic Frost', desc: 'Koel nordic blauw' }
],
backgrounds: [
{ id: 'navy', name: 'Deep Navy', desc: 'Diep marineblauw' },
{ id: 'royal', name: 'Royal Blue', desc: 'Koningsblauw' },
{ id: 'azure', name: 'Azure Sea', desc: 'Helder azuur' },
{ id: 'sky', name: 'Sky Blue', desc: 'Heldere luchtblauw' },
{ id: 'cerulean', name: 'Cerulean', desc: 'Ceruleum' },
{ id: 'teal', name: 'Deep Teal', desc: 'Diep teal' },
{ id: 'lagoon', name: 'Lagoon Blue', desc: 'Lagune blauwgroen' },
{ id: 'cyan', name: 'Cyan Depth', desc: 'Cyaan diepte' },
{ id: 'indigo', name: 'Indigo Field', desc: 'Indigo veld' },
{ id: 'violet', name: 'Violet Dusk', desc: 'Violet schemer' },
{ id: 'sapphire', name: 'Sapphire', desc: 'Saffierblauw' },
{ id: 'midnight', name: 'Midnight', desc: 'Nachtblauw (niet zwart)' },
{ id: 'emerald', name: 'Emerald Sea', desc: 'Smaragd zee' },
{ id: 'slate', name: 'Slate Blue', desc: 'Leisteenblauw' },
{ id: 'graphite', name: 'Graphite Blue', desc: 'Grafietblauw' }
],
current: function () {
try { return localStorage.getItem('flk-theme') || 'ocean'; } catch (e) { return 'ocean'; }
},
currentSidebar: function () {
try { return localStorage.getItem('flk-sidebar-theme') || ''; } catch (e) { return ''; }
},
apply: function (id) {
var ok = this.list.some(function (t) { return t.id === id; });
if (!ok) id = 'ocean';
document.documentElement.setAttribute('data-theme', id);
try { localStorage.setItem('flk-theme', id); } catch (e) {}
return id;
},
applySidebar: function (id) {
if (!id) {
document.documentElement.removeAttribute('data-sidebar');
try { localStorage.removeItem('flk-sidebar-theme'); } catch (e) {}
return '';
}
var migrate = { coral: 'midnight', fuchsia: 'nordic', lime: 'steel' };
if (migrate[id]) id = migrate[id];
var ok = this.sidebars.some(function (t) { return t.id === id; });
if (!ok) {
document.documentElement.removeAttribute('data-sidebar');
try { localStorage.removeItem('flk-sidebar-theme'); } catch (e) {}
return '';
}
document.documentElement.setAttribute('data-sidebar', id);
try { localStorage.setItem('flk-sidebar-theme', id); } catch (e) {}
return id;
},
currentSidebarStyle: function () {
try { return localStorage.getItem('flk-sidebar-style') || 'gradient'; } catch (e) { return 'gradient'; }
},
applySidebarStyle: function (mode) {
if (mode !== 'plain') mode = 'gradient';
if (mode === 'plain') {
document.documentElement.setAttribute('data-sidebar-style', 'plain');
} else {
document.documentElement.removeAttribute('data-sidebar-style');
}
try { localStorage.setItem('flk-sidebar-style', mode); } catch (e) {}
return mode;
},
currentBg: function () {
try { return localStorage.getItem('flk-page-bg') || ''; } catch (e) { return ''; }
},
applyBg: function (id) {
if (!id) {
document.documentElement.removeAttribute('data-bg');
try { localStorage.removeItem('flk-page-bg'); } catch (e) {}
return '';
}
var ok = (this.backgrounds || []).some(function (t) { return t.id === id; });
if (!ok) {
document.documentElement.removeAttribute('data-bg');
try { localStorage.removeItem('flk-page-bg'); } catch (e) {}
return '';
}
document.documentElement.setAttribute('data-bg', id);
try { localStorage.setItem('flk-page-bg', id); } catch (e) {}
return id;
}
};
if (!document.documentElement.getAttribute('data-theme')) {
window.FlkThemes.apply(window.FlkThemes.current());
}
(function () {
var s = window.FlkThemes.currentSidebar();
if (s) window.FlkThemes.applySidebar(s);
if (window.FlkThemes.applySidebarStyle) window.FlkThemes.applySidebarStyle(window.FlkThemes.currentSidebarStyle());
var b = window.FlkThemes.currentBg();
if (b) window.FlkThemes.applyBg(b);
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+71 -104
View File
@@ -2,7 +2,7 @@
{% 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=23" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
@@ -12,7 +12,7 @@
<div>
<p class="hm-hero-eyebrow">Foodlinkk Command Center</p>
<h1>CEO Dashboard <span class="live-badge hm-live-badge">Live</span></h1>
<p class="hero-sub">Cucina B2C · retail · pipeline · Herman briefing</p>
<p class="hero-sub">Foodlinkk B2B · Cucina B2C</p>
</div>
<div class="hm-hero-status" id="hero-live-status">
<span class="hm-live-dot"></span>
@@ -20,14 +20,9 @@
</div>
</div>
<div class="hm-hero-links beurs-mini-row">
<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>
<a href="/clients" class="beurs-mini-chip">Klanten</a>
<a href="/deals" class="beurs-mini-chip">Deals</a>
<a href="/revenue-cockpit" class="beurs-mini-chip">Revenue</a>
</div>
</div>
@@ -49,12 +44,9 @@
<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>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">Genereer briefing</button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">Live data</button>
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? 'Klaar' : 'Indeling'"></button>
</div>
</header>
@@ -76,105 +68,79 @@
</ol>
</div>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="viz-toolbar" class="viz-toolbar" x-show="editLayout" x-cloak></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'">
<div id="briefing-dashboard" class="hm-dash-layout hm-ceo-clean hm-ceo-pro" :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>
<section class="hm-dash-section hm-dash-full hm-ceo-kpis" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Kerncijfers</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 class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Overzicht</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Overzicht</h3>
<div class="hm-chart-panel hm-overview-panel">
<div class="hm-chart-head"><h4>Waar staan we</h4><small>live context</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 · live</h4>
<div id="briefing-summary-text" class="live-digest-body"></div>
</div>
<div class="briefing-card hm-activity-card">
<div class="hm-card-head">
<h4>Activiteitenlog</h4>
<a href="/agents" class="hm-card-link">Agents →</a>
</div>
<ul id="briefing-activity-log" class="hm-activity-timeline"></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-grid"></div>
<div class="hm-ceo-brand-grid" style="margin-top:0.9rem">
<article class="hm-ceo-brand-card brand-foodlinkk" id="ceo-b2b">
<header><span class="hm-ceo-brand-icon">🌍</span><h3>Foodlinkk B2B</h3></header>
<ul class="hm-ceo-brand-list"><li class="muted">Laden…</li></ul>
</article>
<article class="hm-ceo-brand-card brand-cucina" id="ceo-b2c">
<header><span class="hm-ceo-brand-icon">🍽️</span><h3>Cucina B2C</h3></header>
<ul class="hm-ceo-brand-list"><li class="muted">Laden…</li></ul>
</article>
</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-widget-handle" x-show="editLayout">⋮⋮ Sleep · Pipeline</div>
<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 class="hm-chart-head"><h4>Pipeline</h4><small id="ceo-pipeline-sub">revenue + CRM</small></div>
<div class="hm-chart-wrap hm-pipeline-host"><canvas id="chart-pipeline" hidden></canvas></div>
</div>
</div>
<!-- hidden stubs so older renderers don't throw -->
<canvas id="chart-sentiment" hidden></canvas>
<div id="viz-sentiment-alt" hidden></div>
<canvas id="chart-words" hidden></canvas>
</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>
<section class="hm-dash-section hm-dash-full hm-ceo-mid" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="hm-ceo-mid-grid">
<article class="hm-ceo-panel hm-ceo-focus">
<header><h3>🎯 Focus vandaag</h3></header>
<ul id="ceo-focus-list" class="hm-ceo-action-list"><li class="muted">Laden…</li></ul>
</article>
<article class="hm-ceo-panel hm-ceo-trends">
<header><h3>📈 Trends</h3></header>
<ul id="ceo-trends-list" class="hm-ceo-trend-list"><li class="muted">Laden…</li></ul>
</article>
</div>
<details class="hm-ceo-memo" id="ceo-memo-wrap">
<summary class="hm-ceo-memo-label">Dagmemo <small>klik om te openen</small></summary>
<div id="briefing-summary-text" class="hm-ceo-memo-body"></div>
</details>
</section>
<section class="hm-dash-section hm-dash-full hm-ceo-news" data-widget="news">
<details class="hm-ceo-news-details">
<summary>📰 Laatste nieuws <small>max 5 · <a href="/?tab=rss" @click.prevent="setHermanTab('rss')">alle RSS →</a></small></summary>
<div id="ceo-news-list" class="hm-ceo-news-list"></div>
</details>
</section>
<div class="hub-grid hm-dash-feed" data-widget="feed" hidden>
<div id="dash-projects-mini"></div>
</div>
</div>
@@ -263,8 +229,8 @@
{% 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=19"></script>
<script src="/static/js/dashboard-layout.js?v=11"></script>
<script src="/static/js/briefing-charts.js?v=29"></script>
<script>
localStorage.setItem('foodlinkk-persona', 'ceo');
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
@@ -282,7 +248,11 @@ function dashboardBriefing() {
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
this._prefs = prefs;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
window._dashboardKpis = (DashboardLayout.ceoKpis ? DashboardLayout.ceoKpis(prefs) : (prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS));
if (!localStorage.getItem('rc-kpi-migrated-v2')) {
DashboardLayout.saveDashboardConfig(null, null, window._dashboardKpis);
localStorage.setItem('rc-kpi-migrated-v2', '1');
}
const dash = document.getElementById('briefing-dashboard');
const order = DashboardLayout.visibleLayout(prefs);
DashboardLayout.applyOrder(dash, order);
@@ -359,7 +329,7 @@ function dashboardBriefing() {
this._prefs = prefs;
this._vizMode = prefs.global_viz_mode || 'neo-bars';
window._dashboardVizMode = this._vizMode;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
window._dashboardKpis = (DashboardLayout.ceoKpis ? DashboardLayout.ceoKpis(prefs) : (prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS));
const dash = document.getElementById('briefing-dashboard');
this.applyDashboardPrefs(prefs);
this._setDragMode = DashboardLayout.initDrag(dash, (order) => {
@@ -391,7 +361,8 @@ function dashboardBriefing() {
await this.loadProjectsMini();
if (this.liveDigest) {
BriefingCharts.renderLiveSummary(this.liveDigest, false);
} else if (this.content) {
}
if (this.content) {
BriefingCharts.renderText(dash, this.content, this.createdAt);
}
window._refreshBriefingLive = (t) => this.refreshLive(t);
@@ -405,8 +376,6 @@ function dashboardBriefing() {
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');
@@ -509,7 +478,6 @@ function dashboardBriefing() {
} catch (e) {}
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest, { pollRefresh: isPoll });
this.renderClientsMini(this._stats);
this.renderActivityLog(this._stats.activity_log || []);
this.updateLiveMeta(r.at, this.liveDigest);
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
@@ -567,7 +535,6 @@ function dashboardBriefing() {
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
this.renderClientsMini(result.stats || {});
this.renderActivityLog((result.stats || {}).activity_log || []);
document.getElementById('briefing-meta').textContent = 'Rapport: ' + this.createdAt.substring(0, 19).replace('T', ' ') + ' UTC';
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -64,8 +64,8 @@
</div>
<div class="voice-toggles" style="margin-bottom:0.75rem">
<label><input type="checkbox" x-model="speakReply" /> Antwoord voorlezen</label>
<label><input type="checkbox" x-model="handsFreeMode" /> Hands-free na antwoord</label>
<label><input type="checkbox" x-model="speakReply" @change="persistPrefs()" /> Voorlezen (Maarten)</label>
<label><input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" /> Hands-free praten</label>
</div>
<div class="voice-manual-row">
@@ -107,6 +107,6 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/herman-assistant.js?v=1"></script>
<script src="/static/js/herman-assistant.js?v=7"></script>
<style>.voice-mic-inline{width:44px;height:44px;border-radius:50%;font-size:1rem;flex-shrink:0}</style>
{% endblock %}
+230 -206
View File
@@ -1,244 +1,268 @@
{% extends "base.html" %}
{% block title %}Hermes · Telegram Command Center{% endblock %}
{% block title %}Hermes · KPI's{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/hermes.css?v=11" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
{% endblock %}
{% block content %}
<div x-data="hermesControl()" x-init="init()" class="hermes-shell hm-neo">
<div x-data="hermesKpiDashboard()" x-init="init()" class="hermes-shell hm-neo">
<header class="hm-neo-header page-header">
<h1>Hermes — Telegram Command Center</h1>
<p class="hm-sub">Live feed · 4 PA browsers · second brain · volledige controle</p>
<div>
<h1>Hermes · Technische KPI's</h1>
<p class="hm-sub">Live processen van Hermes Agent + Herman — klik een blok voor detail ·
<a href="http://10.4.7.10:9119/" target="_blank" rel="noopener">Hermes UI</a>
<span x-show="kpis.synced_at" class="text-muted" x-text="' · sync ' + formatTime(kpis.synced_at)"></span>
</p>
</div>
<div class="retail-actions">
<span class="status-badge" :class="kpis.online ? 'on' : 'off'" x-text="kpis.online ? 'Online' : 'Offline'"></span>
<button type="button" class="btn btn-sm" @click="refresh()" :disabled="loading">↻ Vernieuwen</button>
</div>
</header>
<!-- Neo KPI row -->
<!-- Zone A -->
<div class="hm-neo-kpi-row">
<div class="hm-neo-kpi" :class="{ highlight: tab === 'feed' }">
<div class="hm-neo-kpi" :class="{ highlight: kpis.online }">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:72%"><div class="hm-neo-ring-inner">💬</div></div>
<div><div class="hm-neo-kpi-label">Conversaties</div><div class="hm-neo-kpi-sub">Telegram chats</div></div>
<div class="hm-neo-ring" :style="'--ring-color:' + (kpis.online ? '#b8ff3c' : '#f87171') + ';--ring-pct:80%'"><div class="hm-neo-ring-inner" x-text="kpis.online ? '●' : '○'"></div></div>
<div><div class="hm-neo-kpi-label">Hermes</div><div class="hm-neo-kpi-sub">Agent status</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="stats.conversations">0</div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div class="hm-neo-kpi-value" style="font-size:1.25rem" x-text="kpis.online ? 'Online' : 'Offline'"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:85%"><div class="hm-neo-ring-inner">📨</div></div>
<div><div class="hm-neo-kpi-label">Berichten</div><div class="hm-neo-kpi-sub">in + out</div></div>
<div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:70%"><div class="hm-neo-ring-inner">✈️</div></div>
<div><div class="hm-neo-kpi-label">Telegram</div><div class="hm-neo-kpi-sub">actieve chats</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="stats.messages">0</div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div class="hm-neo-kpi-value" x-text="ov.telegram_sessions_active || 0"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:60%"><div class="hm-neo-ring-inner">🕸</div></div>
<div><div class="hm-neo-kpi-label">Relaties</div><div class="hm-neo-kpi-sub">graph edges</div></div>
<div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:75%"><div class="hm-neo-ring-inner">🔧</div></div>
<div><div class="hm-neo-kpi-label">Tool-calls</div><div class="hm-neo-kpi-sub">totaal werk</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="stats.edges">0</div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div class="hm-neo-kpi-value" x-text="fmt(ov.tool_calls_total)"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#a855f7;--ring-pct:55%"><div class="hm-neo-ring-inner">🧠</div></div>
<div><div class="hm-neo-kpi-label">Vectors</div><div class="hm-neo-kpi-sub">pgvector</div></div>
<div class="hm-neo-ring" style="--ring-color:#a855f7;--ring-pct:65%"><div class="hm-neo-ring-inner">💬</div></div>
<div><div class="hm-neo-kpi-label">Tekst in / uit</div><div class="hm-neo-kpi-sub">user · agent</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="stats.embeddings">0</div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div class="hm-neo-kpi-value" style="font-size:1.15rem" x-text="(ov.text_in||0) + ' / ' + (ov.text_out||0)"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#38bdf8;--ring-pct:55%"><div class="hm-neo-ring-inner">🧩</div></div>
<div><div class="hm-neo-kpi-label">Skills</div><div class="hm-neo-kpi-sub">geïnstalleerd</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="ov.skills_installed || 0"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#94a3b8;--ring-pct:50%"><div class="hm-neo-ring-inner">⏱</div></div>
<div><div class="hm-neo-kpi-label">Sync</div><div class="hm-neo-kpi-sub">laatste snapshot</div></div>
</div>
<div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="formatTime(kpis.synced_at) || '—'"></div>
</div>
</div>
<!-- Neo charts -->
<div class="hm-neo-charts">
<!-- Zone B -->
<h2 class="hm-section-title">Wat doet hij? <span class="hm-section-hint">klik een blok</span></h2>
<div class="hm-bucket-grid">
<template x-for="b in (kpis.buckets || [])" :key="b.key">
<button type="button" class="hm-bucket-card hm-bucket-click" @click="openBucket(b)">
<div class="hm-bucket-label" x-text="b.label"></div>
<div class="hm-bucket-count" x-text="fmt(b.count)"></div>
<div class="hm-bucket-parts" x-text="partsLine(b.parts)"></div>
<div class="hm-bucket-open">Open →</div>
</button>
</template>
</div>
<!-- Zone C -->
<div class="hm-neo-charts" style="margin-top:1.25rem">
<div class="hm-chart-panel">
<div class="hm-chart-head">
<h3><span class="hm-live-dot"></span> Berichten per richting</h3>
<small>klik voor detail</small>
</div>
<div class="hm-chart-wrap"><canvas id="hm-chart-direction"></canvas></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head">
<h3><span class="hm-live-dot"></span> Agent activiteit</h3>
<small>laatste events</small>
</div>
<div class="hm-chart-wrap"><canvas id="hm-chart-agents"></canvas></div>
</div>
</div>
<div class="hermes-tabs">
<button type="button" class="hermes-tab" :class="{ active: tab === 'feed' }" @click="setTab('feed')">📨 Live Feed</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'pa' }" @click="setTab('pa')">🌐 PA Live (4×)</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'graph' }" @click="setTab('graph')">🕸 Graph</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'search' }" @click="setTab('search')">🔍 Memory</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'control' }" @click="setTab('control')">⚙️ Control</button>
</div>
<!-- LIVE FEED -->
<div x-show="tab === 'feed'" class="hermes-grid">
<aside class="panel hermes-sidebar">
<div class="hm-team">
<h3>Team — Foodlinkk leadership</h3>
<template x-for="m in teamRoster" :key="m.chat_id">
<div class="hm-team-card" :class="{ online: m.online || m.pa_mode }">
<span class="hm-status-dot" :class="m.online ? 'on' : 'off'"></span>
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<div class="hm-person">
<strong x-text="m.name"></strong>
<small>
<span x-text="m.chat_id"></span>
<span x-show="m.pa_mode" class="tag tag-pa">PA</span>
<span x-show="m.message_count" x-text="' · ' + m.message_count + ' msgs'"></span>
</small>
</div>
</div>
<div class="hm-chart-head"><h3>Top tools</h3><small>meest gebruikt</small></div>
<div class="hm-chart-wrap"><canvas id="hm-chart-tools"></canvas></div>
<ul class="hm-tool-list">
<template x-for="t in (kpis.tools_top || []).slice(0,8)" :key="t.name">
<li><span x-text="t.name"></span><strong x-text="fmt(t.count)"></strong></li>
</template>
</div>
<h3 style="font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin:1rem 0 0.5rem">Filter chat</h3>
<button class="btn btn-sm" @click="selectChat(null)" :class="{ 'btn-primary': !selectedChatId }">Alle chats</button>
<template x-for="m in teamRoster" :key="'c'+m.chat_id">
<button class="hermes-conv-btn" @click="selectChat(m.chat_id)" :class="{ active: selectedChatId == m.chat_id }">
<span class="conv-role hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<span x-text="m.name"></span>
</button>
</template>
</aside>
<section class="panel hermes-feed">
<div class="panel-header">
<h3>Telegram live feed</h3>
<span class="text-muted" x-text="live ? '🟢 Live (5s)' : '⏸'"></span>
<button class="btn btn-sm" @click="refreshFeed()">↻ Refresh</button>
</div>
<div class="hermes-messages">
<template x-for="msg in messages" :key="msg.id">
<div class="hermes-msg" :class="msg.direction === 'in' ? 'msg-in' : 'msg-out'">
<div class="hermes-msg-meta">
<span x-text="msg.direction === 'in' ? '← IN' : '→ OUT'"></span>
<span x-text="msgLabel(msg)"></span>
<span x-text="formatTime(msg.created_at)"></span>
<span class="badge" x-show="msg.has_embedding">vector</span>
<span class="badge" x-show="msg.agent_name" x-text="msg.agent_name"></span>
</div>
<div class="hermes-msg-body" x-text="msg.content_text || '(' + (msg.content_type || 'empty') + ')'"></div>
</div>
</template>
<p x-show="!messages.length" class="text-muted">Nog geen berichten — stuur iets via @klaploper_bot</p>
</div>
</section>
</div>
<!-- PA LIVE -->
<div x-show="tab === 'pa'" class="panel">
<div class="hermes-pa-header">
<div>
<strong>Personal PA — 4 site browsers</strong>
<p class="text-muted" style="margin:0.25rem 0 0" x-text="paLive.query ? 'Zoekt: ' + paLive.query : 'Wacht op @PA via Telegram'"></p>
</div>
<span class="status-badge" :class="paLive.status || 'idle'" x-text="paStatusLabel()"></span>
<span class="text-muted" x-show="paLiveUser" x-text="paLiveUser"></span>
<button class="btn btn-sm btn-primary" @click="refreshPaLive()">↻ Refresh</button>
</div>
<div class="hermes-pa-grid">
<template x-for="slot in paSlots" :key="slot.label">
<div class="hermes-pa-slot" :class="'status-' + (slot.status || 'idle')">
<div class="hermes-pa-slot-head">
<strong x-text="slot.label"></strong>
<span class="status-badge" :class="slot.status || 'idle'" x-text="slotStatusLabel(slot.status)"></span>
</div>
<div class="hermes-pa-slot-body">
<img x-show="slot.has_screenshot" :src="slotScreenshotUrl(slot.label)" :alt="slot.label" />
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'waiting'">⏳ Wacht…</div>
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'loading'">🔄 Bezig…</div>
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && (!slot.status || slot.status === 'idle')">— idle —</div>
<div class="hermes-pa-placeholder" x-show="slot.status === 'failed'" x-text="slot.error || 'Mislukt'"></div>
</div>
<div class="hermes-pa-slot-foot" x-text="slot.title || slot.url || slot.label"></div>
</div>
</template>
</div>
</div>
<!-- GRAPH -->
<div x-show="tab === 'graph'" class="panel">
<div class="panel-header">
<h3>Second brain — message graph</h3>
<button class="btn btn-sm btn-primary" @click="loadGraph()">↻ Herlaad</button>
<select class="form-input hermes-select" x-model="graphChatFilter" @change="loadGraph()">
<option value="">Alle chats</option>
<template x-for="m in teamRoster" :key="'g'+m.chat_id">
<option :value="m.chat_id" x-text="m.role + ' — ' + m.name"></option>
</template>
</select>
</div>
<div id="hermes-graph" class="hermes-graph-canvas"></div>
</div>
<!-- SEARCH -->
<div x-show="tab === 'search'" class="panel">
<h3>Vector + full-text memory search</h3>
<div class="hermes-search-bar">
<input class="form-input" type="search" placeholder="Zoek in Telegram-geheugen…" x-model="searchQuery" @keydown.enter="runSearch()" />
<button class="btn btn-primary" @click="runSearch()">Zoeken</button>
</div>
<div class="hermes-search-results">
<template x-for="r in searchResults" :key="r.id">
<div class="hermes-search-hit">
<div class="hermes-msg-meta">
<span x-text="msgLabel(r)"></span>
<span x-text="formatTime(r.created_at)"></span>
<span class="badge" x-text="'score ' + (r.score || 0).toFixed(2)"></span>
</div>
<p x-text="r.content_text"></p>
</div>
</template>
</div>
</div>
<!-- CONTROL -->
<div x-show="tab === 'control'" class="hermes-control-grid">
<div class="panel">
<h3>Hermes status</h3>
<ul class="hermes-info-list">
<li>Bot: <strong>@klaploper_bot</strong></li>
<li>CEO: <strong>Aïssa</strong> · CTO: <strong>Mo</strong></li>
<li>Control API: <span x-text="hermesOnline ? '🟢 Online' : '🔴 Offline'"></span></li>
<li>Inbound: <span x-text="stats.inbound || 0"></span> · Outbound: <span x-text="stats.outbound || 0"></span></li>
</ul>
<div class="hermes-actions">
<button class="btn btn-primary" @click="runAction('evening-briefing')" :disabled="actionBusy">🌙 Avondbriefing → CEO</button>
<button class="btn" @click="runAction('morning-briefing')" :disabled="actionBusy">☀️ Ochtendbriefing</button>
<button class="btn" @click="runAction('resume-jobs')" :disabled="actionBusy">🔧 Herstel jobs</button>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h3>Bronnen</h3><small>via welk kanaal</small></div>
<div class="hm-chart-wrap"><canvas id="hm-chart-sources"></canvas></div>
<ul class="hm-tool-list">
<template x-for="s in (kpis.sources || [])" :key="s.source">
<li>
<span x-text="sourceLabel(s.source)"></span>
<strong x-text="fmt(s.tools) + ' tools · ' + (s.sessions||0) + ' sessies'"></strong>
</li>
</template>
</ul>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h3>Herman (cockpit)</h3><small>agent_events</small></div>
<ul class="hm-tool-list hm-tool-list-tall">
<template x-for="e in (kpis.herman_cockpit || [])" :key="e.event_type">
<li><span x-text="e.event_type"></span><strong x-text="fmt(e.count)"></strong></li>
</template>
<li x-show="!(kpis.herman_cockpit || []).length" class="text-muted">Geen events</li>
</ul>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">
Delegaties: <strong x-text="ov.async_delegations || 0"></strong> ·
Delivery: <strong x-text="ov.delivery_obligations || 0"></strong>
</p>
</div>
</div>
<!-- Explorer / detail modal -->
<div class="hm-modal-backdrop" x-show="modal.open" x-cloak @keydown.escape.window="closeModal()" @click.self="closeModal()">
<div class="hm-modal" role="dialog" aria-modal="true" @click.stop>
<header class="hm-modal-head">
<div>
<h2 x-text="modal.title"></h2>
<p class="hm-modal-sub">
<span x-text="fmt(modal.detail.count || 0) + ' calls'"></span>
<template x-for="(n, name) in (modal.detail.parts || {})" :key="name">
<button type="button" class="hm-chip" :class="{ active: modal.toolFilter === name }"
@click="setToolFilter(name)" x-text="n + ' ' + name.replace(/_/g,' ')"></button>
</template>
<button type="button" class="hm-chip" :class="{ active: !modal.toolFilter }" @click="setToolFilter(null)">alles</button>
</p>
</div>
<button type="button" class="hm-modal-close" @click="closeModal()" aria-label="Sluiten">×</button>
</header>
<div class="hm-modal-tabs" x-show="modal.key === 'files'">
<button type="button" :class="{ active: modal.tab === 'activity' }" @click="modal.tab = 'activity'">Activiteit</button>
<button type="button" :class="{ active: modal.tab === 'touched' }" @click="modal.tab = 'touched'">Aangeraakte files</button>
<button type="button" :class="{ active: modal.tab === 'explorer' }" @click="openExplorerTab()">Verkenner</button>
</div>
<div class="hm-modal-body" :class="{ 'hm-modal-split': true }">
<!-- Left pane -->
<aside class="hm-modal-side">
<div class="hm-side-loading" x-show="modal.loading">Laden…</div>
<!-- Activity list -->
<template x-if="modal.tab === 'activity' || modal.key !== 'files'">
<ul class="hm-event-list">
<template x-for="ev in (modal.detail.events || [])" :key="ev.id">
<li>
<button type="button" class="hm-event-item" :class="{ active: modal.selectedEvent && modal.selectedEvent.id === ev.id }"
@click="selectEvent(ev)">
<div class="hm-event-top">
<strong x-text="ev.title"></strong>
<time x-text="formatTime(ev.created_at)"></time>
</div>
<div class="hm-event-tool" x-text="ev.tool"></div>
<div class="hm-event-preview" x-text="ev.preview"></div>
</button>
</li>
</template>
<li x-show="!(modal.detail.events || []).length && !modal.loading" class="text-muted hm-empty">Geen events</li>
</ul>
</template>
<!-- Touched files -->
<template x-if="modal.key === 'files' && modal.tab === 'touched'">
<ul class="hm-file-list">
<template x-for="f in (modal.detail.files || [])" :key="f.path">
<li>
<button type="button" class="hm-file-item" :class="{ active: modal.preview && modal.preview.path === f.path }"
@click="openFile(f.path)">
<div class="hm-file-name">
<span x-text="f.exists ? '📄' : '◌'"></span>
<strong x-text="f.name"></strong>
</div>
<div class="hm-file-path" x-text="f.path"></div>
<div class="hm-file-meta">
<span x-text="Object.keys(f.ops || {}).map(k => (f.ops[k] + ' ' + k.replace(/_/g,' '))).join(' · ')"></span>
<time x-text="formatTime(f.last_at)"></time>
</div>
</button>
</li>
</template>
<li x-show="!(modal.detail.files || []).length && !modal.loading" class="text-muted hm-empty">Geen bestanden gevonden</li>
</ul>
</template>
<!-- Explorer -->
<template x-if="modal.key === 'files' && modal.tab === 'explorer'">
<div class="hm-explorer">
<div class="hm-explorer-bar">
<button type="button" class="btn btn-sm" @click="explorerUp()" :disabled="!modal.explorer.parent && !modal.explorer.path">↑ Omhoog</button>
<button type="button" class="btn btn-sm" @click="loadExplorer('')">Roots</button>
<code class="hm-explorer-path" x-text="modal.explorer.path || '(roots)'"></code>
</div>
<ul class="hm-file-list">
<template x-for="e in (modal.explorer.entries || [])" :key="e.path">
<li>
<button type="button" class="hm-file-item" @click="e.type === 'dir' ? loadExplorer(e.path) : openFile(e.path)">
<div class="hm-file-name">
<span x-text="e.type === 'dir' ? '📁' : '📄'"></span>
<strong x-text="e.name"></strong>
</div>
<div class="hm-file-meta">
<span x-show="e.size != null" x-text="fmtBytes(e.size)"></span>
<time x-text="formatTime(e.mtime)"></time>
</div>
</button>
</li>
</template>
</ul>
</div>
</template>
</aside>
<!-- Right pane preview -->
<section class="hm-modal-preview">
<div x-show="modal.previewLoading" class="hm-side-loading">Bestand laden…</div>
<template x-if="modal.preview && !modal.previewLoading">
<div>
<div class="hm-preview-head">
<div>
<h3 x-text="modal.preview.name || 'Preview'"></h3>
<code x-text="modal.preview.path || ''"></code>
</div>
<div class="hm-preview-meta" x-show="modal.preview.size != null">
<span x-text="fmtBytes(modal.preview.size)"></span>
<span x-text="formatTime(modal.preview.mtime)"></span>
</div>
</div>
<pre class="hm-preview-code" x-text="modal.preview.content || modal.preview.preview || modal.preview.error || '—'"></pre>
<p class="hm-preview-note" x-show="modal.preview.truncated">Preview afgekapt (bestand te groot).</p>
</div>
</template>
<template x-if="!modal.preview && modal.selectedEvent && !modal.previewLoading">
<div>
<div class="hm-preview-head">
<div>
<h3 x-text="modal.selectedEvent.title"></h3>
<p class="hm-modal-sub" x-text="modal.selectedEvent.tool + ' · ' + formatTime(modal.selectedEvent.created_at)"></p>
</div>
</div>
<pre class="hm-preview-code" x-text="modal.selectedEvent.preview || '—'"></pre>
<div class="hm-preview-paths" x-show="(modal.selectedEvent.paths || []).length">
<template x-for="p in (modal.selectedEvent.paths || [])" :key="p">
<button type="button" class="hm-chip" @click="openFile(p); if(modal.key==='files') modal.tab='touched'" x-text="p"></button>
</template>
</div>
</div>
</template>
<div x-show="!modal.preview && !modal.selectedEvent && !modal.previewLoading" class="hm-empty-pane">
Selecteer een event of bestand links om details te zien.
</div>
</section>
</div>
<p x-show="actionMsg" class="text-muted" style="margin-top:0.5rem" x-text="actionMsg"></p>
</div>
<div class="panel">
<h3>Team</h3>
<template x-for="m in teamRoster" :key="'t'+m.chat_id">
<div class="hm-team-card" :class="{ online: m.online }">
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<div class="hm-person"><strong x-text="m.name"></strong><small x-text="m.chat_id"></small></div>
</div>
</template>
</div>
<div class="panel hermes-events">
<h3>Agent events</h3>
<template x-for="ev in agentEvents" :key="ev.id">
<div class="hermes-event">
<strong x-text="ev.title"></strong>
<span class="badge" x-text="ev.agent_name"></span>
<small x-text="formatTime(ev.created_at)"></small>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/hermes-ui.js"></script>
<script src="/static/js/hermes-ui.js?v=11"></script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0e14" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>Inloggen · Foodlinkk</title>
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/static/css/login.css?v=1" />
</head>
<body class="login-body">
<main class="login-shell">
<section class="login-card">
<img class="login-logo" src="/static/img/foodlinkk-logo.svg" alt="Foodlinkk" width="320" height="80" />
<p class="login-sub">Meld je aan bij de Command Center cockpit</p>
{% if error %}
<div class="login-error" role="alert">{{ error }}</div>
{% endif %}
<form class="login-form" method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next or '/' }}" />
<label class="login-label">Gebruikersnaam
<input class="login-input" type="text" name="username" required autofocus
value="{{ username or '' }}" autocomplete="username" />
</label>
<label class="login-label">Wachtwoord
<input class="login-input" type="password" name="password" required
autocomplete="current-password" />
</label>
<button class="login-btn" type="submit">Inloggen</button>
</form>
<p class="login-foot">Foodlinkk · Cucina · intern netwerk</p>
</section>
</main>
</body>
</html>
+111 -18
View File
@@ -2,6 +2,48 @@
{% 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=13" />
<style>
.reclame-kpi-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 640px) {
.reclame-kpi-row { grid-template-columns: 1fr; }
}
button.reclame-kpi-btn.kpi-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
appearance: none;
-webkit-appearance: none;
background: var(--bg-elevated, #0f172a);
border: 1px solid rgba(56,189,248,0.55);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
button.reclame-kpi-btn.kpi-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(56,189,248,0.18);
border-color: rgba(56,189,248,0.9);
}
button.reclame-kpi-btn.kpi-card:focus-visible {
outline: 2px solid rgba(56,189,248,0.75);
outline-offset: 2px;
}
button.reclame-kpi-btn.kpi-card.is-active {
border-color: #38bdf8;
background: rgba(56,189,248,0.08);
box-shadow: 0 0 0 1px rgba(56,189,248,0.25);
}
button.reclame-kpi-btn.kpi-card span { color: var(--text-secondary, #94a3b8); font-size: 0.85rem; }
button.reclame-kpi-btn.kpi-card strong { color: var(--text-primary, #f8fafc); font-size: 1.15rem; }
</style>
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
@@ -133,17 +175,23 @@
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="reclameBusy" x-text="reclameBusy ? '⏳ Bezig met ophalen…' : '↻ Ophalen van Reclamefolder.nl'"></button>
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
</div>
</div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem" role="group" aria-label="Reclame snelfilters">
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'ketens' }" @click="onReclameKpi('ketens')" title="Open ketens-filter">
<span>Ketens</span><strong x-text="reclameStats.chains||0"></strong>
</button>
<button type="button" class="kpi-card kpi-green clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'folders' }" @click="onReclameKpi('folders')" title="Toon folders">
<span>Folders</span><strong x-text="filteredPromos.length"></strong>
</button>
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'totaal' }" @click="onReclameKpi('totaal')" title="Toon alles / filters wissen">
<span>Totaal geladen</span><strong x-text="promos.length"></strong>
</button>
</div>
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
<select class="form-input" x-ref="chainFilter" x-model="reclameFilter.chain" @change="loadPromos()">
<option value="">Alle ketens</option>
<template x-for="c in reclameChains" :key="c">
<option :value="c" x-text="c"></option>
@@ -177,7 +225,7 @@
</a>
</template>
</div>
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<p x-show="!filteredPromos.length && !reclameBusy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
<form class="form-grid" @submit.prevent="addPromo">
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
@@ -397,10 +445,11 @@ function marketingHub() {
return {
tab: new URLSearchParams(location.search).get('tab') || 'live',
tabIndicatorTop: 0, tabIndicatorH: 44,
busy: false, dashboard: {}, socialAnalytics: '',
busy: false, reclameBusy: false, dashboard: {}, socialAnalytics: '',
regRows: { regelgeving: [], cbs: [], markt: [] },
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameKpi: 'folders',
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
@@ -457,7 +506,30 @@ function marketingHub() {
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
this.tabIndicatorH = btn.offsetHeight;
},
onReclameKpi(which) {
this.reclameKpi = which;
if (which === 'ketens') {
this.$nextTick(() => {
const el = this.$refs.chainFilter;
if (el) {
el.focus();
try { el.showPicker && el.showPicker(); } catch (e) {}
}
});
return;
}
if (which === 'folders') {
this.reclameFilter = { chain: '', q: '', folder_type: 'folder', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
return;
}
this.reclameFilter = { chain: '', q: '', folder_type: '', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
},
async loadReclameChains() {
try {
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
this.reclameChains = d.chains || [];
@@ -471,10 +543,15 @@ function marketingHub() {
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
const resp = await fetch('/api/retail/promo-campaigns?' + params);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
let items = (await resp.json()).items || [];
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
this.promos = items;
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
// Alleen auto-sync bij eerste lege load, niet tijdens handmatige sync (voorkomt nesting)
if (!items.length && !this.reclameBusy && !this.reclameFilter.q && !this.reclameFilter.chain && !this.reclameFilter.folder_type && !this.reclameFilter.valid_days) {
await this.refreshReclamefolders(false);
}
} catch (e) { this.promos = []; }
},
promoUrl(p) {
@@ -489,15 +566,26 @@ function marketingHub() {
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
},
async refreshReclamefolders(toast = true) {
this.busy = true;
if (this.reclameBusy) return;
this.reclameBusy = true;
try {
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
const resp = await fetch('/api/retail/reclamefolder/refresh', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: '{}',
});
const d = await resp.json().catch(() => ({}));
if (!resp.ok || d.ok === false) {
throw new Error(d.detail || d.error || ('HTTP ' + resp.status));
}
await this.loadPromos();
await this.loadReclameChains();
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
if (typeof d.synced === 'number') this.reclameStats.synced = d.synced;
if (toast) Cockpit.toast((d.synced || this.promos.length || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
} catch (e) {
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
} finally { this.busy = false; }
console.error('reclamefolder refresh', e);
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt: ' + (e.message || e), 'error');
} finally { this.reclameBusy = false; }
},
async addPromo() {
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
@@ -545,13 +633,18 @@ function marketingHub() {
},
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
async refreshAll(toast) {
this.busy = true;
const silent = toast === false;
if (!silent) this.busy = true;
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
if (this.tab === 'reclame' && !this.reclameBusy) {
await this.loadPromos();
await this.loadReclameChains();
}
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
if (!silent) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { if (!silent) this.busy = false; }
},
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
+141 -63
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=13" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
@@ -41,17 +41,36 @@
<span class="rc-sum-label">Deals</span>
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
</div>
<div class="rc-visual-toggle" role="group" aria-label="Weergave">
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'table' }" @click="setVisualMode('table')">Tabel</button>
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'bars' }" @click="setVisualMode('bars')">Staven</button>
<button type="button" class="rc-vis-btn" :class="{ active: visualMode === 'cards' }" @click="setVisualMode('cards')">Kaarten</button>
</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>
<div class="rc-style-chips">
<button type="button" class="rc-chip" :class="{ active: !filterStyle }" @click="filterStyle = ''">Alle</button>
<template x-for="s in styleFilters" :key="'c-' + s.id">
<button type="button" class="rc-chip" :class="['rc-chip-' + s.id, { active: filterStyle === s.id }]"
@click="filterStyle = filterStyle === s.id ? '' : s.id"
x-text="s.label"></button>
</template>
</div>
</div>
<div class="rc-visual-wrap">
<div class="rc-goals-banner" x-show="goals.vision_text || goals.horizon_text || goals.tagline">
<span x-text="goals.vision_text"></span>
<span class="rc-goals-sep" x-show="goals.horizon_text">·</span>
<span x-text="goals.horizon_text"></span>
<span class="rc-goals-sep" x-show="goals.mid_text">·</span>
<span x-text="goals.mid_text"></span>
<em x-show="goals.tagline" x-text="goals.tagline"></em>
</div>
<!-- TABEL -->
<div class="rc-visual-wrap" x-show="visualMode === 'table'" x-cloak>
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
<colgroup>
<col class="rc-col-deal" />
@@ -60,15 +79,6 @@
<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>
@@ -76,84 +86,152 @@
<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">
<template x-for="row in visualRows()" :key="'t-' + 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) }]"
:class="['rc-style-' + (row.row_style || 'white'), { 'rc-row-open': selectedKey === 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>
<!-- STAVEN -->
<div class="rc-bars-view" x-show="visualMode === 'bars'" x-cloak>
<template x-for="row in rankedRows()" :key="'b-' + row.source_row + '-' + row.name">
<button type="button" class="rc-bar-row"
:class="['rc-style-' + (row.row_style || 'white'), { active: selectedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<div class="rc-bar-row-top">
<span class="rc-bar-name" x-text="row.name"></span>
<span class="rc-bar-amt" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
<span class="rc-bar-share" x-text="sharePct(row) + '%'"></span>
</div>
<div class="rc-bar-track">
<div class="rc-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
</button>
</template>
</div>
<!-- KAARTEN -->
<div class="rc-cards-view" x-show="visualMode === 'cards'" x-cloak>
<template x-for="row in cardRows()" :key="'k-' + row.source_row + '-' + row.name">
<article class="rc-card"
:class="['rc-style-' + (row.row_style || 'white'), { active: selectedKey === projectKey(row) }]"
@click="toggleExpand(row)">
<div class="rc-card-head">
<h3 x-text="row.name"></h3>
<span class="rc-chip rc-chip-static" :class="'rc-chip-' + (row.row_style || 'white')" x-text="styleLabel(row.row_style)"></span>
</div>
<div class="rc-card-metrics">
<div>
<span>mnd</span>
<strong x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></strong>
</div>
<div x-show="row.margin_year != null">
<span>jr</span>
<strong x-text="fmtEuroCompact(row.margin_year)"></strong>
</div>
<div>
<span>aandeel</span>
<strong x-text="sharePct(row) + '%'"></strong>
</div>
</div>
<div class="rc-card-bar" aria-hidden="true">
<div :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
</div>
<p class="rc-card-steps" x-text="truncate(row.next_steps, 100) || 'Geen next step'"></p>
</article>
</template>
</div>
<!-- DETAIL PANEEL -->
<aside class="rc-detail" x-show="selectedProject" x-cloak>
<div class="rc-detail-head">
<div>
<h3 x-text="selectedProject?.name"></h3>
<span class="rc-chip rc-chip-static" :class="'rc-chip-' + (selectedProject?.row_style || 'white')"
x-text="styleLabel(selectedProject?.row_style)"></span>
</div>
<button type="button" class="rc-detail-close" @click="selectedKey = null; expandedKey = null">✕</button>
</div>
<div class="rc-detail-grid">
<div class="rc-detail-stat">
<span>Marge / mnd</span>
<strong x-text="fmtEuro(selectedProject?.margin_month)"></strong>
</div>
<div class="rc-detail-stat" x-show="selectedProject?.margin_year != null">
<span>Marge / jr</span>
<strong x-text="fmtEuro(selectedProject?.margin_year)"></strong>
</div>
<div class="rc-detail-stat">
<span>Portfolio</span>
<strong x-text="sharePct(selectedProject || {}) + '%'"></strong>
</div>
<div class="rc-detail-ring" :style="donutStyle(selectedProject || {})">
<span x-text="sharePct(selectedProject || {}) + '%'"></span>
</div>
</div>
<div class="rc-detail-compare">
<div class="rc-detail-compare-col">
<div class="rc-detail-vbar" :style="'height:' + marginBarPct(selectedProject || {}, 'margin_month') + '%;background:' + styleColor(selectedProject?.row_style)"></div>
<span>Mnd</span>
</div>
<div class="rc-detail-compare-col" x-show="selectedProject?.margin_year != null">
<div class="rc-detail-vbar rc-detail-vbar-year" :style="'height:' + marginBarPct(selectedProject || {}, 'margin_year') + '%'"></div>
<span>Jr</span>
</div>
</div>
<div class="rc-detail-status">
<span class="rc-detail-label">Zet status</span>
<div class="rc-style-chips rc-style-chips-assign">
<template x-for="s in styleFilters" :key="'as-' + s.id">
<button type="button" class="rc-chip" :class="['rc-chip-' + s.id, { active: selectedProject?.row_style === s.id }]"
@click="setCockpitStyle(s.id)" x-text="s.label"></button>
</template>
</div>
<p class="rc-detail-hint" x-show="selectedProject && !selectedProject.db_id">Sync DB om status permanent te bewaren</p>
</div>
<div class="rc-detail-steps">
<h4>Next steps</h4>
<p x-text="selectedProject?.next_steps || 'Geen next steps in Excel'"></p>
</div>
<div class="rc-detail-task" x-show="selectedProject?.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…" />
<button type="button" class="btn btn-sm btn-pulse" @click="assignTask()">→</button>
</div>
</div>
</aside>
</div>
<!-- Bewerken: originele Excel -->
@@ -258,5 +336,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=6"></script>
<script src="/static/js/revenue-cockpit.js?v=13"></script>
{% endblock %}
+433 -244
View File
@@ -11,8 +11,11 @@
<nav class="settings-tabs">
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
<a href="/settings?tab=ai" class="settings-tab {% if active_tab == 'ai' %}active{% endif %}">AI / LLM</a>
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
{% if can_manage_users %}
<a href="/settings?tab=users" class="settings-tab {% if active_tab == 'users' %}active{% endif %}">Gebruikers</a>
{% endif %}
<a href="/settings?tab=themes" class="settings-tab {% if active_tab == 'themes' %}active{% endif %}">Themes</a>
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
</nav>
@@ -114,109 +117,6 @@
</div>
</div>
</div>
{% elif active_tab == 'ai' %}
<section class="panel settings-panel" x-data="llmSettingsPage()" x-init="load()">
<div class="settings-panel-head">
<div>
<h2>AI / LLM providers</h2>
<p class="page-tip">Koppel cloud API's (DeepSeek, Gemini, Groq, OpenRouter) naast lokale Ollama. Standaard provider wordt gebruikt in chat, Herman en documenten.</p>
</div>
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuwe provider</button>
</div>
<div class="settings-active-banner" x-show="defaultProvider" x-cloak>
<span class="badge badge-positive">Standaard</span>
<span x-text="defaultProvider ? defaultProvider.label + ' · ' + defaultProvider.model : ''"></span>
</div>
<div class="llm-preset-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.65rem;margin-bottom:1.25rem">
<template x-for="pr in presets" :key="pr.id">
<div class="perm-card" style="cursor:pointer" @click="quickAdd(pr)">
<strong x-text="pr.label"></strong>
<p class="page-tip" style="margin:0.35rem 0" x-text="pr.hint"></p>
<small x-text="(pr.models||[]).slice(0,2).join(', ')"></small>
</div>
</template>
</div>
<div class="email-account-list">
<template x-for="p in providers" :key="p.id">
<article class="email-account-card" :class="{ 'is-active': p.is_default }">
<div class="email-account-card-head">
<div>
<strong x-text="p.label"></strong>
<span class="muted" x-text="p.preset_label + ' · ' + p.model"></span>
</div>
<span class="badge" :class="p.is_default ? 'badge-positive' : (p.is_active ? 'badge-neutral' : 'badge-muted')"
x-text="p.is_default ? 'Standaard' : (p.is_active ? 'Actief' : 'Inactief')"></span>
</div>
<div class="email-account-meta">
<span x-text="p.api_base_url || 'Ollama lokaal'"></span>
<span x-text="p.api_key_set ? '🔒 API key opgeslagen' : (p.needs_key ? '⚠️ geen API key' : 'Geen key nodig')"></span>
<span x-show="p.last_test_status" x-text="'Test: ' + p.last_test_status"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-sm" @click="edit(p)">Bewerken</button>
<button type="button" class="btn btn-primary btn-sm" @click="activate(p.id)" x-show="!p.is_default">Maak standaard</button>
<button type="button" class="btn btn-secondary btn-sm" @click="testProvider(p.id)">Test</button>
<button type="button" class="btn btn-reject btn-sm" @click="remove(p.id)" x-show="p.provider_type !== 'ollama' || providers.length > 1">Verwijder</button>
</div>
</article>
</template>
<p class="empty-state" x-show="!providers.length && !loading">Nog geen LLM provider — voeg DeepSeek, Gemini of Groq toe.</p>
</div>
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
<div class="modal-card settings-modal">
<h3 x-text="form.id ? 'Provider bewerken' : 'Nieuwe LLM provider'"></h3>
<label class="form-label">Label
<input class="form-input" x-model="form.label" placeholder="bv. DeepSeek productie" />
</label>
<label class="form-label">Type
<select class="form-input" x-model="form.provider_type" @change="applyPreset()">
<template x-for="pr in presets" :key="'fp'+pr.id">
<option :value="pr.id" x-text="pr.label"></option>
</template>
</select>
</label>
<label class="form-label">Model
<input class="form-input" x-model="form.model" list="llm-model-list" placeholder="deepseek-chat" />
<datalist id="llm-model-list">
<template x-for="m in currentPresetModels()" :key="m">
<option :value="m"></option>
</template>
</datalist>
</label>
<label class="form-label" x-show="formNeedsKey()">API base URL
<input class="form-input" x-model="form.api_base_url" placeholder="https://api.deepseek.com/v1" />
</label>
<label class="form-label" x-show="formNeedsKey()">API key
<input class="form-input" type="password" x-model="form.api_key"
:placeholder="form.api_key_set ? '•••••••• (laat leeg om te behouden)' : 'sk-...'" />
</label>
<p class="page-tip" x-show="presetHint()" x-text="presetHint()"></p>
<p class="page-tip" x-show="presetSignup()" >
<a :href="presetSignup()" target="_blank" rel="noopener">→ Gratis API key aanmaken</a>
</p>
<label class="form-check">
<input type="checkbox" x-model="form.is_default" /> Instellen als standaard provider
</label>
<div class="btn-group">
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving">Opslaan</button>
<button type="button" class="btn btn-secondary" @click="testForm()" :disabled="saving">Test</button>
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleren</button>
</div>
</div>
</div>
</section>
{% elif active_tab == 'permissions' %}
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
<div class="settings-panel-head">
@@ -242,6 +142,111 @@
</template>
</div>
</section>
{% elif active_tab == 'users' and can_manage_users %}
<section class="panel settings-panel hm-neo" x-data="usersAdminPage()" x-init="load()">
<div class="settings-panel-head">
<div>
<h2>Gebruikers &amp; rechten</h2>
<p class="page-tip">Maak accounts aan en bepaal welke cockpit-pagina's zichtbaar zijn. Admins hebben altijd volledige toegang.</p>
</div>
<button type="button" class="btn btn-primary" @click="openCreate()">+ Nieuwe gebruiker</button>
</div>
<div class="email-account-list">
<template x-for="u in users" :key="u.id">
<article class="email-account-card" :class="{ 'is-active': u.is_active }">
<div class="email-account-card-head">
<div style="display:flex;align-items:center;gap:0.75rem;min-width:0">
<img class="user-card-avatar" x-show="u.avatar_url" :src="u.avatar_url" alt="" />
<span class="user-card-avatar fallback" x-show="!u.avatar_url" x-text="initials(u)"></span>
<div style="min-width:0">
<strong x-text="u.display_name"></strong>
<span class="muted" x-text="'@' + u.username"></span>
</div>
</div>
<span class="badge" :class="u.role === 'admin' ? 'badge-positive' : 'badge-neutral'" x-text="u.role"></span>
</div>
<div class="email-account-meta">
<span x-text="u.is_active ? 'Actief' : 'Uitgeschakeld'"></span>
<span x-text="u.role === 'admin' ? 'Alle pagina\'s' : ((u.permissions.pages||[]).length + ' pagina\'s')"></span>
<span x-show="u.last_login_at" x-text="'Laatste login: ' + (u.last_login_at || '')"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-sm" @click="edit(u)">Bewerken</button>
<button type="button" class="btn btn-reject btn-sm" @click="remove(u)" x-show="u.id !== meId">Verwijder</button>
</div>
</article>
</template>
<p class="empty-state" x-show="!users.length && !loading">Nog geen gebruikers.</p>
</div>
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
<div class="modal-card settings-modal" style="max-width:560px">
<h3 x-text="form.id ? 'Gebruiker bewerken' : 'Nieuwe gebruiker'"></h3>
<div class="user-form-avatar-row">
<img class="user-form-avatar-preview" x-show="form.avatar_url || form.avatarPreview"
:src="form.avatarPreview || form.avatar_url" alt="" />
<span class="user-form-avatar-preview fallback" x-show="!form.avatar_url && !form.avatarPreview"
x-text="formInitials()"></span>
<div>
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif"
@change="onAvatarPick($event)" />
<p class="page-tip" style="margin:0.35rem 0 0">JPG/PNG/WEBP, max 3 MB. Bij nieuwe user: eerst opslaan, daarna foto.</p>
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.35rem"
x-show="form.id && form.avatar_url" @click="removeAvatar()">Foto verwijderen</button>
</div>
</div>
<label class="form-label">Gebruikersnaam
<input class="form-input" x-model="form.username" :disabled="!!form.id" placeholder="bijv. sandra" autocomplete="off" />
</label>
<label class="form-label">Weergavenaam
<input class="form-input" x-model="form.display_name" placeholder="Sandra" />
</label>
<label class="form-label">Wachtwoord <span class="muted" x-show="form.id">(leeg = ongewijzigd)</span>
<input class="form-input" type="password" x-model="form.password" autocomplete="new-password" />
</label>
<label class="form-label">Rol
<select class="form-input" x-model="form.role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<label class="form-check" x-show="form.id">
<input type="checkbox" x-model="form.is_active" /> Account actief
</label>
<label class="form-check">
<input type="checkbox" x-model="form.manage_users" :disabled="form.role === 'admin'" /> Mag gebruikers beheren
</label>
<div x-show="form.role !== 'admin'" style="margin-top:0.75rem">
<strong style="font-size:0.85rem">Pagina-rechten</strong>
<div class="perm-grid" style="margin-top:0.5rem;grid-template-columns:1fr 1fr">
<template x-for="p in pages" :key="p.key">
<label class="form-check">
<input type="checkbox" :value="p.key" :checked="form.pages.includes(p.key)"
@change="togglePage(p.key, $event.target.checked)" />
<span x-text="p.label"></span>
</label>
</template>
</div>
<div class="btn-group" style="margin-top:0.5rem">
<button type="button" class="btn btn-sm btn-secondary" @click="selectAllPages()">Alles</button>
<button type="button" class="btn btn-sm btn-secondary" @click="form.pages=[]">Geen</button>
</div>
</div>
<p class="page-tip" x-show="form.role === 'admin'">Admins hebben automatisch toegang tot alle pagina's en gebruikersbeheer.</p>
<div class="btn-group" style="margin-top:1rem">
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving" x-text="saving ? 'Bezig…' : 'Opslaan'"></button>
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleer</button>
</div>
</div>
</div>
</section>
{% elif active_tab == 'social' %}
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
<div class="settings-panel-head">
@@ -278,6 +283,68 @@
</template>
</div>
</section>
{% elif active_tab == 'themes' %}
<section class="panel settings-panel" x-data="flkThemesPage()" x-init="init()">
<div class="settings-panel-head">
<div>
<h2>UI Themes</h2>
<p class="page-tip">UI-theme, pagina-achtergrond en sidebar-kleur zijn los van elkaar te kiezen.</p>
</div>
</div>
<div class="flk-theme-grid">
<template x-for="t in themes" :key="t.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': current === t.id }" @click="select(t.id)">
<div class="flk-theme-swatch" :style="swatch(t.id)"></div>
<strong x-text="t.name"></strong>
<small x-text="t.desc"></small>
</button>
</template>
</div>
<div class="flk-theme-section">
<h3>Pagina achtergrond</h3>
<p class="page-tip">15 echte kleuren voor alle subpagina’s (niet zwart). Los van UI-theme en sidebar.</p>
<div class="flk-theme-grid">
<button type="button" class="flk-theme-card" :class="{ 'is-active': !bg }" @click="selectBg('')">
<div class="flk-theme-swatch" style="background:linear-gradient(135deg,#07131f,#123049)"></div>
<strong>Standaard</strong>
<small>Volgt UI-theme achtergrond</small>
</button>
<template x-for="b in backgrounds" :key="'bg-'+b.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': bg === b.id }" @click="selectBg(b.id)">
<div class="flk-theme-swatch" :style="bgSwatch(b.id)"></div>
<strong x-text="b.name"></strong>
<small x-text="b.desc"></small>
</button>
</template>
</div>
</div>
<div class="flk-theme-section">
<h3>Sidebar Themes</h3>
<p class="page-tip">Los van het UI-theme — 10 diepe, strakke kleuren (geen rood/roze). Kies gradient of effen.</p>
<div class="flk-style-toggle" role="group" aria-label="Sidebar stijl">
<button type="button" :class="{ 'is-active': sidebarStyle === 'gradient' }" @click="selectSidebarStyle('gradient')">Gradient</button>
<button type="button" :class="{ 'is-active': sidebarStyle === 'plain' }" @click="selectSidebarStyle('plain')">Effen kleur</button>
</div>
<div class="flk-theme-grid">
<button type="button" class="flk-theme-card" :class="{ 'is-active': !sidebar }" @click="selectSidebar('')">
<div class="flk-theme-swatch" style="background:linear-gradient(180deg,#080c12,#121820)"></div>
<strong>Standaard</strong>
<small>Geen aparte sidebar-kleur (volgt UI)</small>
</button>
<template x-for="s in sidebars" :key="'sb-'+s.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': sidebar === s.id }" @click="selectSidebar(s.id)">
<div class="flk-theme-swatch" :data-sb="s.id" :data-sb-style="sidebarStyle" :style="sidebarSwatch(s.id)"></div>
<strong x-text="s.name"></strong>
<small x-text="s.desc"></small>
</button>
</template>
</div>
</div>
</section>
{% elif active_tab == 'general' %}
<section class="panel settings-panel">
<h2>General</h2>
@@ -289,6 +356,122 @@
{% endblock %}
{% block scripts %}
<script>
function flkThemesPage() {
return {
themes: (window.FlkThemes && window.FlkThemes.list) || [],
sidebars: (window.FlkThemes && window.FlkThemes.sidebars) || [],
backgrounds: (window.FlkThemes && window.FlkThemes.backgrounds) || [],
current: 'ocean',
sidebar: '',
sidebarStyle: 'gradient',
bg: '',
init() {
this.themes = (window.FlkThemes && window.FlkThemes.list) || [];
this.sidebars = (window.FlkThemes && window.FlkThemes.sidebars) || [];
this.backgrounds = (window.FlkThemes && window.FlkThemes.backgrounds) || [];
this.current = (window.FlkThemes && window.FlkThemes.current()) || 'ocean';
this.sidebar = (window.FlkThemes && window.FlkThemes.currentSidebar()) || '';
this.sidebarStyle = (window.FlkThemes && window.FlkThemes.currentSidebarStyle && window.FlkThemes.currentSidebarStyle()) || 'gradient';
this.bg = (window.FlkThemes && window.FlkThemes.currentBg && window.FlkThemes.currentBg()) || '';
if (window.FlkThemes) {
window.FlkThemes.apply(this.current);
window.FlkThemes.applySidebar(this.sidebar);
if (window.FlkThemes.applySidebarStyle) window.FlkThemes.applySidebarStyle(this.sidebarStyle);
if (window.FlkThemes.applyBg) window.FlkThemes.applyBg(this.bg);
}
},
select(id) {
this.current = window.FlkThemes.apply(id);
if (window.Cockpit) Cockpit.toast('UI theme: ' + id, 'ok');
},
selectSidebar(id) {
this.sidebar = window.FlkThemes.applySidebar(id || '');
if (window.Cockpit) Cockpit.toast(id ? ('Sidebar: ' + id) : 'Sidebar: standaard', 'ok');
},
selectSidebarStyle(mode) {
this.sidebarStyle = window.FlkThemes.applySidebarStyle(mode || 'gradient');
if (window.Cockpit) Cockpit.toast(mode === 'plain' ? 'Sidebar: effen kleur' : 'Sidebar: gradient', 'ok');
},
selectBg(id) {
this.bg = window.FlkThemes.applyBg(id || '');
if (window.Cockpit) Cockpit.toast(id ? ('Achtergrond: ' + id) : 'Achtergrond: standaard', 'ok');
},
swatch(id) {
var map = {
ocean: 'linear-gradient(135deg,#07131f,#123049 40%,#22d3ee)',
aurora: 'linear-gradient(135deg,#0a1020,#1a2550 45%,#a78bfa 75%,#22d3ee)',
neon: 'linear-gradient(135deg,#050b14,#10243f 40%,#06b6d4 70%,#e879f9)',
glacier: 'linear-gradient(135deg,#0b1522,#1a2f4a 45%,#38bdf8 80%,#7dd3fc)',
cobalt: 'linear-gradient(135deg,#08101c,#172642 40%,#3b82f6 70%,#22d3ee)',
reef: 'linear-gradient(135deg,#04161a,#11353f 40%,#2dd4bf)',
plasma: 'linear-gradient(135deg,#0c0618,#26114a 40%,#c084fc 75%,#22d3ee)',
lagoon: 'linear-gradient(135deg,#031a1f,#0c3a45 40%,#22d3ee)',
indigo: 'linear-gradient(135deg,#07091a,#1a2250 40%,#818cf8 75%,#22d3ee)',
skyline: 'linear-gradient(135deg,#06101c,#142d4d 40%,#38bdf8)',
mintvolt: 'linear-gradient(135deg,#04140f,#10352a 40%,#34d399 70%,#22d3ee)',
sapphire: 'linear-gradient(135deg,#050d1f,#122650 40%,#3b82f6 70%,#38bdf8)',
arctic: 'linear-gradient(135deg,#0a121c,#1c2a3d 45%,#67e8f9)',
hyperion: 'linear-gradient(135deg,#041018,#0e2d42 40%,#22d3ee 70%,#a3e635)',
meridian: 'linear-gradient(135deg,#0a0716,#221645 40%,#c084fc 70%,#22d3ee)'
};
return 'background: ' + (map[id] || map.ocean) + ' !important;';
},
sidebarSwatch(id) {
var style = this.sidebarStyle || 'gradient';
var solids = {
aqua: '#0a4f5c',
azure: '#163a7a',
turquoise: '#0a453f',
cerulean: '#0c4a6e',
electric: '#0a3d4a',
violet: '#2a1f6b',
emerald: '#0a4a38',
midnight: '#0d1f3c',
steel: '#1a2d42',
nordic: '#0e3a4f',
};
var grads = {
aqua: 'linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%)',
azure: 'linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%)',
turquoise: 'linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%)',
cerulean: 'linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%)',
electric: 'linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%)',
violet: 'linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%)',
emerald: 'linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%)',
midnight: 'linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%)',
steel: 'linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%)',
nordic: 'linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%)',
};
var bg = (style === 'plain')
? (solids[id] || solids.electric)
: (grads[id] || grads.electric);
return 'background: ' + bg + ' !important;';
},
bgSwatch(id) {
var map = {
navy: 'linear-gradient(135deg,#0d2b5c,#134078)',
royal: 'linear-gradient(135deg,#1a3a8f,#274bb8)',
azure: 'linear-gradient(135deg,#0a4f8a,#0d6cb8)',
sky: 'linear-gradient(135deg,#0e5f9e,#1880c9)',
cerulean: 'linear-gradient(135deg,#0c6a9e,#1490c8)',
teal: 'linear-gradient(135deg,#0c5c68,#128090)',
lagoon: 'linear-gradient(135deg,#0a6b7a,#0f8fa3)',
cyan: 'linear-gradient(135deg,#0a5568,#0e7a94)',
indigo: 'linear-gradient(135deg,#2f2a7a,#4338ca)',
violet: 'linear-gradient(135deg,#4a1d7a,#6d28d9)',
sapphire: 'linear-gradient(135deg,#123a8c,#1d4ed8)',
midnight: 'linear-gradient(135deg,#1a2f55,#274878)',
emerald: 'linear-gradient(135deg,#0d5c45,#059669)',
slate: 'linear-gradient(135deg,#243447,#334155)',
graphite: 'linear-gradient(135deg,#1e2a3a,#2c3b52)'
};
return 'background: ' + (map[id] || map.navy) + ' !important;';
}
};
}
</script>
<script>
function socialSettingsPage() {
const META = {
@@ -375,6 +558,152 @@ function socialSettingsPage() {
};
}
function usersAdminPage() {
return {
loading: false,
saving: false,
users: [],
pages: [],
meId: null,
formOpen: false,
form: { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: [] },
async load() {
this.loading = true;
try {
const me = await Cockpit.api('/api/auth/me');
this.meId = me.user && me.user.id;
const r = await Cockpit.api('/api/settings/users');
this.users = r.users || [];
this.pages = r.pages || [];
} catch (e) {
Cockpit.toast((e && e.message) || 'Laden mislukt', 'error');
} finally {
this.loading = false;
}
},
openCreate() {
this.form = { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: ['dashboard'], avatar_url: null, avatarPreview: null, avatarFile: null };
this.formOpen = true;
},
edit(u) {
this.form = {
id: u.id,
username: u.username,
display_name: u.display_name || '',
password: '',
role: u.role || 'user',
is_active: !!u.is_active,
manage_users: !!(u.permissions && u.permissions.manage_users),
pages: (u.permissions && u.permissions.pages) ? [...u.permissions.pages] : [],
avatar_url: u.avatar_url || null,
avatarPreview: null,
avatarFile: null,
};
this.formOpen = true;
},
initials(u) {
const n = ((u && (u.display_name || u.username)) || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return n.slice(0, 2).toUpperCase();
},
formInitials() {
return this.initials({ display_name: this.form.display_name, username: this.form.username });
},
onAvatarPick(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
this.form.avatarFile = file;
this.form.avatarPreview = URL.createObjectURL(file);
},
async uploadAvatarFor(userId, file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/settings/users/' + userId + '/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Foto upload mislukt');
return data.user;
},
async removeAvatar() {
if (!this.form.id) return;
if (!confirm('Profielfoto verwijderen?')) return;
try {
await Cockpit.api('/api/settings/users/' + this.form.id + '/avatar', { method: 'DELETE' });
this.form.avatar_url = null;
this.form.avatarPreview = null;
this.form.avatarFile = null;
Cockpit.toast('Foto verwijderd', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Mislukt', 'error');
}
},
togglePage(key, on) {
if (on && !this.form.pages.includes(key)) this.form.pages.push(key);
if (!on) this.form.pages = this.form.pages.filter(k => k !== key);
},
selectAllPages() { this.form.pages = this.pages.map(p => p.key); },
async save() {
this.saving = true;
try {
const permissions = {
pages: this.form.role === 'admin' ? this.pages.map(p => p.key) : this.form.pages,
manage_users: this.form.role === 'admin' ? true : !!this.form.manage_users,
};
let saved = null;
if (this.form.id) {
const body = {
display_name: this.form.display_name,
role: this.form.role,
is_active: this.form.is_active,
permissions,
};
if (this.form.password) body.password = this.form.password;
saved = await Cockpit.api('/api/settings/users/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
saved = saved.user || saved;
} else {
if (!this.form.username || !this.form.password) {
Cockpit.toast('Gebruikersnaam en wachtwoord verplicht', 'error');
return;
}
saved = await Cockpit.api('/api/settings/users', {
method: 'POST',
body: JSON.stringify({
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name || this.form.username,
role: this.form.role,
permissions,
}),
});
saved = saved.user || saved;
}
if (this.form.avatarFile && saved && saved.id) {
await this.uploadAvatarFor(saved.id, this.form.avatarFile);
}
this.formOpen = false;
Cockpit.toast('Gebruiker opgeslagen', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Opslaan mislukt', 'error');
} finally {
this.saving = false;
}
},
async remove(u) {
if (!confirm('Verwijder gebruiker ' + u.username + '?')) return;
try {
await Cockpit.api('/api/settings/users/' + u.id, { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Verwijderen mislukt', 'error');
}
},
};
}
function permissionsPage() {
return {
perms: [], grantedCount: 0, total: 0,
@@ -532,145 +861,5 @@ function defaultForm() {
};
}
</script>
{% elif active_tab == 'ai' %}
<script>
function llmDefaultForm() {
return {
id: null, label: '', provider_type: 'deepseek', api_base_url: '', api_key: '',
model: 'deepseek-chat', is_active: true, is_default: false, api_key_set: false,
};
}
function llmSettingsPage() {
return {
providers: [],
presets: [],
defaultProvider: null,
loading: true,
formOpen: false,
saving: false,
form: llmDefaultForm(),
async load() {
this.loading = true;
try {
const r = await Cockpit.api('/api/settings/llm');
this.providers = r.providers || [];
this.presets = r.presets || [];
this.defaultProvider = r.default || this.providers.find(p => p.is_default) || null;
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.loading = false;
},
openForm() {
this.form = llmDefaultForm();
this.applyPreset();
this.formOpen = true;
},
quickAdd(pr) {
this.form = llmDefaultForm();
this.form.provider_type = pr.id;
this.form.label = pr.label;
this.applyPreset();
this.formOpen = true;
},
edit(p) {
this.form = Object.assign(llmDefaultForm(), p, { api_key: '' });
this.formOpen = true;
},
applyPreset() {
const pr = this.presets.find(x => x.id === this.form.provider_type) || {};
if (pr.api_base_url) this.form.api_base_url = pr.api_base_url;
if ((pr.models || []).length && !this.form.id) this.form.model = pr.models[0];
if (!this.form.label) this.form.label = pr.label || this.form.provider_type;
},
currentPresetModels() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return (pr && pr.models) ? pr.models : [];
},
formNeedsKey() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? !!pr.needs_key : true;
},
presetHint() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? (pr.hint || '') : '';
},
presetSignup() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? (pr.signup_url || '') : '';
},
bodyFromForm() {
const b = {
label: this.form.label,
provider_type: this.form.provider_type,
api_base_url: this.form.api_base_url,
model: this.form.model,
is_active: true,
is_default: this.form.is_default,
extra_config: {},
};
if (this.form.api_key) b.api_key = this.form.api_key;
return b;
},
async save() {
this.saving = true;
try {
const body = this.bodyFromForm();
if (this.form.id) {
await Cockpit.api('/api/settings/llm/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/api/settings/llm', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('LLM provider opgeslagen', 'success');
this.formOpen = false;
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.saving = false;
},
async testForm() {
if (!this.form.id) {
Cockpit.toast('Sla eerst op, daarna testen', 'info');
return;
}
await this.testProvider(this.form.id);
},
async testProvider(id) {
try {
const r = await Cockpit.api('/api/settings/llm/' + id + '/test', { method: 'POST' });
Cockpit.toast(r.message || 'OK', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async activate(id) {
try {
await Cockpit.api('/api/settings/llm/' + id + '/activate', { method: 'POST' });
Cockpit.toast('Standaard LLM bijgewerkt', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!confirm('Deze LLM provider verwijderen?')) return;
try {
await Cockpit.api('/api/settings/llm/' + id, { method: 'DELETE' });
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
</script>
{% endif %}
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Certificaat · Cockpit{% endblock %}
{% block content %}
<div class="card" style="max-width:640px;margin:2rem auto;padding:1.25rem 1.4rem">
<h1 style="margin-top:0;font-size:1.25rem">Eenmalig: vertrouw Cockpit</h1>
<p class="page-tip">Daarna geen “onveilige website”-melding meer, en de microfoon werkt direct.</p>
<ol style="line-height:1.55;padding-left:1.2rem;color:#e2e8f0">
<li>Download het CA-certificaat:
<a class="btn btn-sm btn-pulse" href="http://10.4.7.18:8600/cockpit-local-ca.crt">Download CA</a>
</li>
<li>Windows: dubbelklik → <strong>Certificaat installeren</strong> →
<em>Lokale computer</em> → plaats in <strong>Vertrouwde basiscertificeringsautoriteiten</strong> → Voltooien.</li>
<li>Of PowerShell als Administrator:
<code style="display:block;margin:.4rem 0;padding:.5rem;background:#0b1220;border-radius:6px;font-size:.75rem">certutil -addstore -f Root \$env:USERPROFILE\\Downloads\\foodlinkk-cockpit-ca.crt</code>
</li>
<li>Chrome/Edge volledig afsluiten en opnieuw openen, daarna
<a href="https://10.4.7.18:8643/">https://10.4.7.18:8643/</a>
</li>
</ol>
</div>
{% endblock %}
+2 -2
View File
@@ -63,7 +63,7 @@
</label>
<label>
<input type="checkbox" x-model="speakReply" />
<span>Antwoord voorlezen (browser TTS)</span>
<span>Voorlezen (Maarten)</span>
</label>
</div>
@@ -164,5 +164,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/voice-live.js?v=3"></script>
<script src="/static/js/voice-live.js?v=4"></script>
{% endblock %}
+51 -2
View File
@@ -37,6 +37,8 @@ services:
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
ports:
- "8700:8700"
volumes:
@@ -45,6 +47,18 @@ services:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: foodlinkk_whisper
environment:
WHISPER__MODEL: Systran/faster-whisper-base
WHISPER__INFERENCE_DEVICE: cpu
ports:
- "8877:8000"
volumes:
- whisper_models:/root/.cache/huggingface
restart: unless-stopped
cockpit:
build: ./cockpit
container_name: foodlinkk_cockpit
@@ -63,21 +77,53 @@ services:
BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
# OpenRouter — vul later in (.env of Instellingen → AI / LLM)
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_MODEL: ${OPENROUTER_MODEL:-deepseek/deepseek-v4-flash}
OPENROUTER_BASE_URL: ${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HERMAN_LLM_BACKEND: ${HERMAN_LLM_BACKEND:-hermes}
HERMES_API_BASE: ${HERMES_API_BASE:-http://10.4.7.10:8642/v1}
HERMES_API_KEY: ${HERMES_API_KEY:-}
HERMES_API_MODEL: ${HERMES_API_MODEL:-hermes-agent}
HERMES_FEED_URL: ${HERMES_FEED_URL:-http://10.4.7.10:8755}
WHISPER_API_URL: ${WHISPER_API_URL:-http://whisper:8000/v1/audio/transcriptions}
WHISPER_MODEL: ${WHISPER_MODEL:-Systran/faster-whisper-base}
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-nl}
HERMES_BUILD_URL: http://10.4.7.27:8798
HERMES_PREVIEW_BASE: http://10.4.7.27:8080
NAS_CLIENTS_ROOT: /data/nas-clients
NAS_SHARE_ROOT: /data/nas-share
NAS_AGENTS_ROOT: /data/nas-share/Agents
NAS_AGENTS_SMB: //10.4.7.11/share/Agents
NAS_AGENTS_UNC: //10.4.7.11/share/Agents
ports:
- "8600:8600"
- "8610:8600"
volumes:
- /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro
- /mnt/synology:/data/nas-share
- ./cockpit/static/uploads:/app/static/uploads
depends_on:
- tools-api
- whisper
extra_hosts:
- "foodlinkk_db:10.4.7.18"
restart: unless-stopped
cockpit-tls:
image: caddy:2-alpine
container_name: foodlinkk_cockpit_tls
depends_on:
- cockpit
ports:
- "8643:8643"
volumes:
- ./cockpit/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./cockpit/caddy/certs:/certs:ro
- cockpit_caddy_data:/data
- cockpit_caddy_config:/config
restart: unless-stopped
browser-agent:
build: ./browser-agent
container_name: foodlinkk_browser_agent
@@ -152,3 +198,6 @@ services:
volumes:
gitea_data:
cockpit_caddy_data:
cockpit_caddy_config:
whisper_models:
+14
View File
@@ -0,0 +1,14 @@
-- OpenRouter + DeepSeek V4 Flash placeholder (API key via Settings UI)
INSERT INTO llm_providers (label, provider_type, api_base_url, api_key, model, is_active, is_default, extra_config)
SELECT
'OpenRouter · DeepSeek V4 Flash',
'openrouter',
'https://openrouter.ai/api/v1',
'',
'deepseek/deepseek-v4-flash',
FALSE,
FALSE,
'{"referer":"https://foodlinkk.local","title":"Foodlinkk Command Center"}'::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM llm_providers WHERE provider_type = 'openrouter'
);
+18
View File
@@ -0,0 +1,18 @@
-- Cockpit login users + page permissions
CREATE TABLE IF NOT EXISTS cockpit_users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
permissions JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_cockpit_users_username ON cockpit_users (username);
COMMENT ON TABLE cockpit_users IS 'Foodlinkk Cockpit UI login accounts';
COMMENT ON COLUMN cockpit_users.permissions IS 'JSON: {"pages":["dashboard","revenue",...], "manage_users": true}';
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE cockpit_users
ADD COLUMN IF NOT EXISTS avatar_url TEXT;
COMMENT ON COLUMN cockpit_users.avatar_url IS 'Public URL under /static/uploads/avatars/…';
+38
View File
@@ -0,0 +1,38 @@
-- War Room + 1:1 agent chat rooms/messages
CREATE TABLE IF NOT EXISTS warroom_rooms (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('warroom', 'dm')),
agent_key VARCHAR(64) REFERENCES agent_souls(agent_key) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT warroom_rooms_kind_agent CHECK (
(kind = 'warroom' AND agent_key IS NULL) OR
(kind = 'dm' AND agent_key IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS warroom_rooms_one_warroom
ON warroom_rooms ((kind)) WHERE kind = 'warroom';
CREATE UNIQUE INDEX IF NOT EXISTS warroom_rooms_dm_agent
ON warroom_rooms (agent_key) WHERE kind = 'dm';
CREATE TABLE IF NOT EXISTS warroom_messages (
id BIGSERIAL PRIMARY KEY,
room_id BIGINT NOT NULL REFERENCES warroom_rooms(id) ON DELETE CASCADE,
sender_type TEXT NOT NULL CHECK (sender_type IN ('user', 'agent', 'system')),
agent_key VARCHAR(64),
username TEXT,
sender_avatar_url TEXT,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS warroom_messages_room_created
ON warroom_messages (room_id, created_at);
INSERT INTO warroom_rooms (kind, agent_key, title)
SELECT 'warroom', NULL, 'War Room'
WHERE NOT EXISTS (SELECT 1 FROM warroom_rooms WHERE kind = 'warroom');
@@ -0,0 +1,7 @@
-- Soft-delete for War Room messages
ALTER TABLE warroom_messages
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS warroom_messages_room_deleted
ON warroom_messages (room_id, deleted_at)
WHERE deleted_at IS NOT NULL;
+18
View File
@@ -0,0 +1,18 @@
-- CEO direct inbox (@ervoorz / @aissa)
CREATE TABLE IF NOT EXISTS warroom_ceo_inbox (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES warroom_messages(id) ON DELETE CASCADE,
room_id BIGINT REFERENCES warroom_rooms(id) ON DELETE CASCADE,
agent_key VARCHAR(64),
content TEXT NOT NULL,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
recipient TEXT NOT NULL DEFAULT 'aissa'
);
CREATE INDEX IF NOT EXISTS warroom_ceo_inbox_unread
ON warroom_ceo_inbox (recipient, read_at, created_at DESC);
CREATE INDEX IF NOT EXISTS warroom_ceo_inbox_created
ON warroom_ceo_inbox (created_at DESC);
+9 -1
View File
@@ -8,7 +8,7 @@ import urllib.request
import uuid
from datetime import datetime, timezone
COCKPIT_URL = "http://127.0.0.1:8600"
COCKPIT_URL = "http://127.0.0.1:8610"
TOOLS_URL = "http://127.0.0.1:8700"
@@ -68,6 +68,13 @@ def run_chain_0700(cid: str) -> None:
_handoff("sysops", "herman", "infra_status", {"step": "daily_ops"}, cid)
def run_chain_creative(cid: str) -> None:
_handoff("marketing", "design", "assets", {"step": "morning_creative_brief"}, cid)
_handoff("design", "packaging", "artwork", {"step": "packaging_visuals"}, cid)
_handoff("design", "webbuilder", "assets", {"step": "web_assets"}, cid)
def run_chain_0800(cid: str) -> None:
_handoff("email", "bizdev", "leads", {"step": "morning_leads_digest"}, cid)
_handoff("retail", "bizdev", "opportunities", {"step": "top_opportunities"}, cid)
@@ -83,6 +90,7 @@ def main() -> int:
run_chain_0630(str(uuid.uuid4()))
run_chain_0700(str(uuid.uuid4()))
run_chain_0800(str(uuid.uuid4()))
run_chain_creative(str(uuid.uuid4()))
try:
_post(f"{COCKPIT_URL}/api/herman/briefing", timeout=300)
+1 -1
View File
@@ -8,7 +8,7 @@ import urllib.request
def main() -> int:
url = "http://127.0.0.1:8600/api/herman/briefing"
url = "http://127.0.0.1:8610/api/herman/briefing"
req = urllib.request.Request(url, data=b"{}", headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
+1 -1
View File
@@ -26,7 +26,7 @@ def main() -> int:
try:
req = urllib.request.Request(
"http://127.0.0.1:8600/api/herman/briefing",
"http://127.0.0.1:8610/api/herman/briefing",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Daily Herman War Room digest → Telegram Aïssa + Mo (runs inside cockpit container)."""
from __future__ import annotations
import json
import subprocess
import sys
def main() -> int:
code = r"""
import asyncio, json
from app.services.warroom_digest import run_daily_digest
res = asyncio.run(run_daily_digest(hours=24, deliver=True))
print(json.dumps({
"ok": res.get("ok"),
"message_count": res.get("message_count"),
"generated_at": res.get("generated_at"),
"digest_preview": (res.get("digest") or "")[:600],
"deliveries": [
{"name": d.get("name"), "ok": d.get("ok"), "confirm": (d.get("confirm") or d.get("error") or "")[:160]}
for d in (res.get("deliveries") or [])
],
}, ensure_ascii=False))
"""
try:
proc = subprocess.run(
["docker", "exec", "-i", "foodlinkk_cockpit", "python", "-c", code],
capture_output=True,
text=True,
timeout=360,
)
except Exception as exc:
print("War Room digest failed:", exc, file=sys.stderr)
return 1
if proc.returncode != 0:
print(proc.stdout)
print(proc.stderr, file=sys.stderr)
return proc.returncode or 1
print(proc.stdout.strip())
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -6,7 +6,7 @@ import json
import sys
import urllib.request
COCKPIT_URL = "http://127.0.0.1:8600"
COCKPIT_URL = "http://127.0.0.1:8610"
TOOLS_URL = "http://127.0.0.1:8700"
+476 -9
View File
@@ -6,6 +6,7 @@ import io
import hashlib
import json
import time
from collections import defaultdict
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query
@@ -38,6 +39,241 @@ def _halal_reason_labels(reasons: list[str]) -> list[str]:
return out
def _normalize_url(url: Any) -> Optional[str]:
if url is None:
return None
s = str(url).strip()
if not s or s.lower() in ("null", "none", "-", "—"):
return None
if s.startswith("mailto:") or s.startswith("tel:"):
return s
if not s.startswith(("http://", "https://")):
s = "https://" + s.lstrip("/")
return s
def _display_url(url: str) -> str:
return url.replace("https://", "").replace("http://", "").rstrip("/")[:96]
def _collect_websites(
row: dict[str, Any],
contacts: Optional[list[dict[str, Any]]] = None,
brand_website: Optional[str] = None,
) -> list[dict[str, str]]:
seen: set[str] = set()
out: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
href = _normalize_url(raw)
if not href:
return
key = href.lower().rstrip("/")
if key in seen:
return
seen.add(key)
out.append({
"label": (label or "Website")[:64],
"url": href,
"display": _display_url(href),
})
add("Organisatie", row.get("website"))
add("Brand", brand_website)
for c in contacts or []:
role = (c.get("role") or c.get("name") or "Contact").strip()
add(role, c.get("website_contact_url"))
if c.get("linkedin_url"):
add("LinkedIn", c.get("linkedin_url"))
src = c.get("source_url")
if src and "linkedin.com" not in str(src).lower():
add("Bron", src)
return out
def _clean_str(val: Any) -> Optional[str]:
if val is None:
return None
s = str(val).strip()
if not s or s.lower() in ("null", "none", "-", "—", "n/a"):
return None
return s
def _dedupe_items(items: list[dict[str, str]], key: str = "value") -> list[dict[str, str]]:
seen: set[str] = set()
out: list[dict[str, str]] = []
for item in items:
val = _clean_str(item.get(key))
if not val:
continue
k = val.lower()
if k in seen:
continue
seen.add(k)
out.append({**item, key: val})
return out
def _collect_emails(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
val = _clean_str(raw)
if not val or "@" not in val:
return
items.append({"label": (label or "E-mail")[:64], "value": val, "href": f"mailto:{val}"})
add("Organisatie", row.get("email"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("email"))
alt = _clean_str(c.get("email_alt"))
if alt and alt != _clean_str(c.get("email")):
add(f"{who} (alt)", alt)
return _dedupe_items(items)
def _collect_phones(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any, kind: str = "tel") -> None:
val = _clean_str(raw)
if not val:
return
digits = "".join(ch for ch in val if ch.isdigit() or ch == "+")
href = f"https://wa.me/{digits.lstrip('+')}" if kind == "wa" and digits else f"tel:{val}"
items.append({"label": (label or "Telefoon")[:64], "value": val, "href": href, "kind": kind})
add("Organisatie", row.get("phone"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("phone"))
add(f"{who} mobiel", c.get("mobile"))
add(f"{who} WhatsApp", c.get("whatsapp"), "wa")
add(f"{who} fax", c.get("fax"))
return _dedupe_items(items)
def _collect_addresses(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, line: Any, city: Any = None, postal: Any = None) -> None:
parts = [_clean_str(line), _clean_str(postal), _clean_str(city)]
parts = [p for p in parts if p]
if not parts:
return
val = ", ".join(parts)
items.append({"label": (label or "Adres")[:64], "value": val})
add("Locatie", row.get("address_line"), row.get("city"), row.get("postal_code"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("address_line"), c.get("city"), c.get("postal_code"))
return _dedupe_items(items)
def _collect_social(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
href = _normalize_url(raw)
if not href:
return
items.append({"label": label[:64], "value": _display_url(href), "href": href})
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
if c.get("linkedin_url"):
add(f"LinkedIn · {who}", c.get("linkedin_url"))
return _dedupe_items(items, key="href")
def _public_contacts(contacts: Optional[list[dict[str, Any]]], limit: int = 8) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for c in (contacts or [])[:limit]:
out.append({
"name": _clean_str(c.get("name")),
"role": _clean_str(c.get("role")),
"title": _clean_str(c.get("title")),
"department": _clean_str(c.get("department")),
"email": _clean_str(c.get("email")),
"email_alt": _clean_str(c.get("email_alt")),
"phone": _clean_str(c.get("phone")),
"mobile": _clean_str(c.get("mobile")),
"whatsapp": _clean_str(c.get("whatsapp")),
"fax": _clean_str(c.get("fax")),
"linkedin_url": _normalize_url(c.get("linkedin_url")),
"website": _normalize_url(c.get("website_contact_url")),
"is_primary": bool(c.get("is_primary")),
"source": _clean_str(c.get("source")),
})
return out
def _build_reach(
row: dict[str, Any],
contacts: Optional[list[dict[str, Any]]] = None,
brand_website: Optional[str] = None,
) -> dict[str, Any]:
lat, lon = row.get("lat"), row.get("lon")
maps_url = row.get("maps_url")
if not maps_url and lat is not None and lon is not None:
maps_url = f"https://www.google.com/maps?q={lat},{lon}"
place_id = _clean_str(row.get("google_place_id"))
if place_id and not maps_url:
maps_url = f"https://www.google.com/maps/search/?api=1&query_place_id={place_id}"
reach = {
"emails": _collect_emails(row, contacts),
"phones": _collect_phones(row, contacts),
"websites": _collect_websites(row, contacts, brand_website),
"addresses": _collect_addresses(row, contacts),
"social": _collect_social(row, contacts),
"maps_url": maps_url,
"google_place_id": place_id,
"postal_code": _clean_str(row.get("postal_code")),
"contact_people": _public_contacts(contacts),
}
reach["has_any"] = bool(
reach["emails"] or reach["phones"] or reach["websites"]
or reach["addresses"] or reach["social"] or reach["maps_url"]
)
return reach
def _map_contacts_batch(entity_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
if not entity_ids:
return {}
rows = fetch_all(
"""
SELECT entity_id, role, name, title, department, email, email_alt,
phone, mobile, whatsapp, fax, linkedin_url, website_contact_url,
source_url, address_line, city, postal_code, is_primary, confidence, source
FROM export_entity_contacts
WHERE entity_id = ANY(%s)
ORDER BY is_primary DESC NULLS LAST, confidence DESC NULLS LAST, id
""",
(entity_ids,),
)
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
grouped[int(row["entity_id"])].append(row)
return grouped
def _brand_websites(codes: list[str]) -> dict[str, Optional[str]]:
if not codes:
return {}
rows = fetch_all(
"SELECT code, website FROM export_caterer_brands WHERE code = ANY(%s)",
(codes,),
)
return {r["code"]: r.get("website") for r in rows}
def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[str, Any]:
row["contacts"] = contacts
row["contact_count"] = len(contacts)
@@ -50,6 +286,15 @@ def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[
row["phone"] = phone
primary = contacts[0] if contacts else None
row["primary_contact"] = primary
brand_website = row.pop("brand_website", None)
row["reach"] = _build_reach(row, contacts, brand_website)
row["websites"] = row["reach"]["websites"]
if row["websites"] and not row.get("website"):
row["website"] = row["websites"][0]["url"]
if row["reach"]["emails"] and not row.get("email"):
row["email"] = row["reach"]["emails"][0]["value"]
if row["reach"]["phones"] and not row.get("phone"):
row["phone"] = row["reach"]["phones"][0]["value"]
h_score, h_reasons = score_entity(row)
row["halal_score"] = h_score
row["halal_reasons"] = h_reasons
@@ -87,7 +332,7 @@ def _map_entity_limit(
crm_linked: Optional[bool],
) -> int:
if q or entity_type or entity_types or halal_min is not None or favorite_only or crm_linked is not None:
return 4000
return 8000
if country:
return 12000
if region:
@@ -118,6 +363,49 @@ DISTRIBUTOR_TYPES = ("distributor", "wholesaler", "importer", "logistics", "cold
CUSTOMER_TYPES = ("restaurant", "doner_shoarma", "butcher", "foodservice")
CATERER_TYPES = ("contract_caterer")
TYPE_FILTER_ALIASES: dict[str, tuple[str, ...]] = {
"b2b": DISTRIBUTOR_TYPES,
"distributor": DISTRIBUTOR_TYPES,
"importer": DISTRIBUTOR_TYPES,
"logistics": DISTRIBUTOR_TYPES,
"horeca": CUSTOMER_TYPES,
"restaurant": CUSTOMER_TYPES,
"doner_shoarma": CUSTOMER_TYPES,
"butcher": CUSTOMER_TYPES,
"foodservice": CUSTOMER_TYPES,
"caterer": CATERER_TYPES,
"contract_caterer": CATERER_TYPES,
}
def _expand_entity_types(
entity_type: Optional[str],
entity_types: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
expanded: list[str] = []
if entity_types:
for raw in entity_types.split(","):
key = raw.strip()
if not key:
continue
alias = TYPE_FILTER_ALIASES.get(key)
expanded.extend(alias if alias else (key,))
if entity_type:
key = entity_type.strip()
alias = TYPE_FILTER_ALIASES.get(key)
expanded.extend(alias if alias else (key,))
if not expanded:
return entity_type, entity_types
deduped: list[str] = []
seen: set[str] = set()
for key in expanded:
if key not in seen:
seen.add(key)
deduped.append(key)
if len(deduped) == 1:
return deduped[0], None
return None, ",".join(deduped)
class ContactCreateIn(BaseModel):
role: str = "general"
@@ -186,6 +474,7 @@ def _entity_filters(
clauses.append("t.region_code = %s")
params.append(region)
clauses.append("e.territory_code = t.code")
entity_type, entity_types = _expand_entity_types(entity_type, entity_types)
if entity_type:
clauses.append("e.entity_type = %s")
params.append(entity_type)
@@ -228,7 +517,24 @@ def export_stats(
return int(row["n"] or 0)
entities_n = count("")
distributors_n = count("entity_type IN ('distributor','wholesaler','importer','logistics')")
type_geo_rows = fetch_all(
"""
SELECT entity_type, COUNT(*) AS n
FROM export_market_entities
WHERE lat IS NOT NULL AND lon IS NOT NULL
GROUP BY entity_type
"""
)
entity_types_geo = {r["entity_type"]: int(r["n"] or 0) for r in type_geo_rows}
country_count_rows = fetch_all(
"""
SELECT country_iso2, COUNT(*) AS n
FROM export_market_entities
GROUP BY country_iso2
"""
)
country_entity_counts = {r["country_iso2"]: int(r["n"] or 0) for r in country_count_rows}
distributors_n = count("entity_type IN ('distributor','wholesaler','importer','logistics','cold_storage','port_agent')")
caterers_n = count("entity_type = 'contract_caterer'")
contacts = fetch_one("SELECT COUNT(*) AS n FROM export_entity_contacts")
with_email = fetch_one(
@@ -259,6 +565,8 @@ def export_stats(
"crm_linked": int(crm_row["n"] or 0) if crm_row else 0,
"phase": "B",
"enrichment_status": "active",
"entity_types_geo": entity_types_geo,
"country_entity_counts": country_entity_counts,
}
@@ -267,16 +575,31 @@ def list_regions() -> list[dict[str, Any]]:
return fetch_all("SELECT * FROM export_regions ORDER BY sort_order, code")
def _dedupe_territories(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
iso = row.get("country_iso2")
if iso and iso in seen:
continue
if iso:
seen.add(iso)
out.append(row)
return out
@router.get("/territories")
def list_territories(region: Optional[str] = Query(None)) -> list[dict[str, Any]]:
if region:
return fetch_all(
rows = fetch_all(
"SELECT * FROM export_territories WHERE is_active = TRUE AND region_code = %s ORDER BY sync_priority, name_nl",
(region,),
)
return fetch_all(
"SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl"
)
else:
rows = fetch_all(
"SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl"
)
return _dedupe_territories(rows)
@router.get("/entities")
@@ -328,10 +651,12 @@ def list_entities(
def get_entity(entity_id: int) -> dict[str, Any]:
row = fetch_one(
"""
SELECT e.*, cl.name AS crm_client_name, d.title AS crm_deal_title, d.stage AS crm_deal_stage
SELECT e.*, cl.name AS crm_client_name, d.title AS crm_deal_title, d.stage AS crm_deal_stage,
b.website AS brand_website
FROM export_market_entities e
LEFT JOIN clients cl ON cl.id = e.client_id
LEFT JOIN deals d ON d.id = e.deal_id
LEFT JOIN export_caterer_brands b ON b.code = e.brand_code
WHERE e.id = %s
""",
(entity_id,),
@@ -614,9 +939,9 @@ def map_bundle(
entities = fetch_all(
f"""
SELECT e.id, e.name, e.entity_type, e.country_iso2, e.lat, e.lon,
e.confidence, e.pipeline_stage, e.city, e.address_line,
e.confidence, e.pipeline_stage, e.city, e.address_line, e.postal_code,
e.email, e.phone, e.website, e.volume_band, e.source,
e.is_favorite, e.client_id, e.deal_id,
e.brand_code, e.is_favorite, e.client_id, e.deal_id,
cp.contact_email, cp.contact_phone,
COALESCE(cc.contact_count, 0) AS contact_count
FROM export_market_entities e
@@ -641,6 +966,11 @@ def map_bundle(
(tuple(params) + (row_limit,)) if params else (row_limit,),
)
entity_ids = [int(r["id"]) for r in entities]
contacts_by_entity = _map_contacts_batch(entity_ids)
brand_codes = list({r["brand_code"] for r in entities if r.get("brand_code")})
brands_by_code = _brand_websites(brand_codes)
scored: list[tuple[dict[str, Any], float, list[str]]] = []
halal_by_country: dict[str, dict[str, Any]] = {}
for row in entities:
@@ -678,8 +1008,23 @@ def map_bundle(
"entity_type": row["entity_type"],
"country_iso2": row["country_iso2"],
"city": row.get("city"),
"address_line": row.get("address_line"),
"email": row.get("email") or row.get("contact_email"),
"phone": row.get("phone") or row.get("contact_phone"),
"website": row.get("website"),
"reach": _build_reach(
row,
contacts_by_entity.get(int(row["id"]), []),
brands_by_code.get(row.get("brand_code")),
),
"websites": _build_reach(
row,
contacts_by_entity.get(int(row["id"]), []),
brands_by_code.get(row.get("brand_code")),
)["websites"],
"volume_band": row.get("volume_band"),
"confidence": row.get("confidence"),
"pipeline_stage": row.get("pipeline_stage"),
"contact_count": int(row.get("contact_count") or 0),
"halal_score": h_score,
"halal_tier": halal_pin_tier(h_score),
@@ -971,6 +1316,128 @@ def api_sync_all(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
raise HTTPException(500, str(exc)) from exc
@router.post("/sync/missing")
def api_sync_missing(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
try:
from app.export_intel_sync import sync_missing_countries
result = sync_missing_countries(max_priority=payload.max_priority or 3)
return {"status": "completed", "phase": "B", "result": result}
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
class ExportIntelSettingsIn(BaseModel):
google_maps_api_key: Optional[str] = None
@router.get("/settings")
def export_intel_settings_get() -> dict[str, Any]:
from app.export_intel_places import google_api_key_status
return google_api_key_status()
@router.put("/settings")
def export_intel_settings_put(payload: ExportIntelSettingsIn) -> dict[str, Any]:
from app.export_intel_places import google_api_key_status, set_google_api_key
if payload.google_maps_api_key is not None:
set_google_api_key(payload.google_maps_api_key)
return google_api_key_status()
@router.post("/sync/places")
def api_sync_places(payload: SyncRequestIn) -> dict[str, Any]:
from app.export_intel_places import enrich_entities_places_country, sync_places_country
from app.export_intel_sync import _countries_for_sync
from app.export_intel_sync_progress import finish_job, job_step, start_job, set_active_job
if payload.country_iso2:
job_id = start_job("places", f"Google Places · {payload.country_iso2.upper()}", total=1)
try:
job_step(job_id, message=f"Google Places {payload.country_iso2.upper()}", current=1, total=1)
result = sync_places_country(payload.country_iso2.upper())
finish_job(job_id, result=result)
return {"status": "completed", "phase": "B", "result": result}
except Exception as exc:
finish_job(job_id, error=str(exc))
raise HTTPException(500, str(exc)) from exc
finally:
set_active_job(None)
countries = _countries_for_sync(region_code=payload.region_code, max_priority=payload.max_priority or 3)
if not countries:
raise HTTPException(400, "country_iso2 or region_code required")
job_id = start_job("places", f"Google Places · {len(countries)} landen", total=len(countries))
results: dict[str, Any] = {}
try:
from app.export_intel_sync_progress import SyncCancelled, raise_if_cancelled
for i, c in enumerate(countries, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Google Places {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
results[c] = sync_places_country(c)
time.sleep(1)
finish_job(job_id, result={"countries": len(countries)})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise HTTPException(500, str(exc)) from exc
finally:
set_active_job(None)
return {"status": "completed", "phase": "B", "result": results}
@router.post("/sync/cancel")
def api_sync_cancel() -> dict[str, Any]:
"""Request cancellation of the currently running sync job(s)."""
from app.export_intel_sync_progress import request_cancel, get_monitor_snapshot
out = request_cancel("user")
snap = get_monitor_snapshot()
out["any_running"] = snap.get("any_running")
out["active_jobs"] = [{"id": j.get("id"), "label": j.get("label"), "status": j.get("status")} for j in snap.get("active_jobs", [])]
return out
@router.get("/sync/status")
def sync_status() -> dict[str, Any]:
from app.export_intel_sync_progress import get_monitor_snapshot
total_row = fetch_one("SELECT COUNT(*) AS n FROM export_market_entities")
terr_row = fetch_one("SELECT COUNT(*) AS n FROM export_territories WHERE is_active = TRUE")
with_data = fetch_one(
"""
SELECT COUNT(DISTINCT country_iso2) AS n FROM export_market_entities
WHERE country_iso2 IS NOT NULL
"""
)
zero_rows = fetch_all(
"""
SELECT t.country_iso2, t.name_nl, t.region_code
FROM export_territories t
WHERE t.is_active
AND NOT EXISTS (
SELECT 1 FROM export_market_entities e WHERE e.country_iso2 = t.country_iso2
)
ORDER BY t.region_code, t.name_nl
"""
)
type_rows = fetch_all(
"""
SELECT entity_type, COUNT(*) AS n FROM export_market_entities GROUP BY entity_type ORDER BY n DESC
"""
)
coverage = {
"entities_total": int(total_row["n"] or 0) if total_row else 0,
"territories_total": int(terr_row["n"] or 0) if terr_row else 0,
"countries_with_data": int(with_data["n"] or 0) if with_data else 0,
"countries_empty": len(zero_rows),
"countries_empty_list": zero_rows[:30],
"entity_types": {r["entity_type"]: int(r["n"] or 0) for r in type_rows},
}
snap = get_monitor_snapshot(coverage)
return snap
@router.post("/sync/world")
def api_sync_world(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
"""Sync Europe, Middle East, Africa, Americas — long running."""
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Background sync with live progress tracking."""
import time
from app.export_intel_sync import sync_customers, sync_missing_countries
from app.export_intel_sync_progress import finish_job, job_step, start_job
REGIONS = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean", "asia", "oceania"]
def main():
job_id = start_job("background", "Volledige wereld-update", total=len(REGIONS) + 1)
try:
job_step(job_id, message="Lege landen synchroniseren…", step="missing", current=0, total=len(REGIONS) + 1)
sync_missing_countries(max_priority=3)
for i, region in enumerate(REGIONS, start=1):
job_step(job_id, message=f"Horeca regio {i}/{len(REGIONS)}: {region}", step="horeca", current=i, total=len(REGIONS) + 1)
sync_customers(region_code=region)
time.sleep(2)
finish_job(job_id, result={"regions": len(REGIONS)})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
if __name__ == "__main__":
main()
+216 -41
View File
@@ -1,20 +1,26 @@
"""Google Places sync for Export Intel (optional — requires GOOGLE_MAPS_API_KEY)."""
"""Google Places sync + entity enrichment for Export Intel."""
from __future__ import annotations
import json
import math
import os
import time
from typing import Any, Optional
import httpx
from app.db import fetch_one
from app.db import execute, fetch_all, fetch_one
from app.export_intel_sync import _get_territory, _upsert_contact
PLACES_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json"
DETAILS_URL = "https://maps.googleapis.com/maps/api/place/details/json"
FIND_URL = "https://maps.googleapis.com/maps/api/place/findplacefromtext/json"
SETTINGS_KEY = "export_intel.google_maps_api_key"
QUERIES = [
"groothandel groente fruit",
"wholesale food",
"meat wholesaler",
"halal meat distributor",
"poultry distributor",
@@ -22,13 +28,58 @@ QUERIES = [
"contract catering",
"food importer",
"halal butcher",
"horeca groothandel",
]
LARGE_COUNTRIES = frozenset({"US", "CA", "BR", "MX", "AR", "AU", "RU", "CN", "IN", "CD", "DZ", "EG", "IR", "SA"})
def get_google_api_key() -> Optional[str]:
env = (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY") or "").strip()
if env:
return env
row = fetch_one("SELECT value FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
if not row or not row.get("value"):
return None
val = row["value"]
if isinstance(val, dict):
key = (val.get("api_key") or val.get("key") or "").strip()
return key or None
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, dict):
return (parsed.get("api_key") or parsed.get("key") or "").strip() or None
except Exception:
return val.strip() or None
return None
def set_google_api_key(api_key: str) -> None:
key = (api_key or "").strip()
if not key:
execute("DELETE FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
return
execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(SETTINGS_KEY, json.dumps({"api_key": key})),
)
def google_api_key_status() -> dict[str, Any]:
key = get_google_api_key()
return {
"google_maps_api_key_set": bool(key),
"source": "env" if (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY")) else ("db" if key else None),
}
def _api_key() -> Optional[str]:
return os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY")
return get_google_api_key()
def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) -> list[dict[str, Any]]:
@@ -51,6 +102,46 @@ def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) ->
return data.get("results", [])[:12]
def _find_place_id(name: str, lat: float, lon: float, radius_m: int = 3000) -> Optional[str]:
key = _api_key()
if not key or not name.strip():
return None
params = {
"input": name.strip()[:128],
"inputtype": "textquery",
"locationbias": f"circle:{radius_m}@{lat},{lon}",
"fields": "place_id,name,geometry",
"key": key,
}
with httpx.Client(timeout=20.0) as client:
resp = client.get(FIND_URL, params=params)
if resp.status_code >= 400:
return None
data = resp.json()
if data.get("status") != "OK":
return None
cands = data.get("candidates") or []
if not cands:
return None
best = cands[0]
geo = best.get("geometry", {}).get("location", {})
clat, clon = geo.get("lat"), geo.get("lng")
if clat is not None and clon is not None:
dist = _haversine_m(lat, lon, float(clat), float(clon))
if dist > max(radius_m * 2, 8000):
return None
return best.get("place_id")
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _place_details(place_id: str) -> dict[str, Any]:
key = _api_key()
if not key:
@@ -60,7 +151,7 @@ def _place_details(place_id: str) -> dict[str, Any]:
DETAILS_URL,
params={
"place_id": place_id,
"fields": "name,formatted_phone_number,website,formatted_address,geometry,international_phone_number",
"fields": "name,formatted_phone_number,website,formatted_address,geometry,international_phone_number,place_id",
"key": key,
},
)
@@ -84,55 +175,132 @@ def _bbox_points(bbox: Any, grid: int = 3) -> list[tuple[float, float]]:
return points
def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2: str, territory_code: str, query: str) -> tuple[str, int]:
from app.db import execute, execute_returning
def _apply_place_to_entity(entity_id: int, place_id: str, details: dict[str, Any]) -> bool:
phone = details.get("formatted_phone_number") or details.get("international_phone_number")
website = details.get("website")
geo = details.get("geometry", {}).get("location", {})
plat, plon = geo.get("lat"), geo.get("lng")
execute(
"""
UPDATE export_market_entities SET
phone = COALESCE(%s, phone),
website = COALESCE(%s, website),
google_place_id = COALESCE(google_place_id, %s),
lat = COALESCE(%s, lat),
lon = COALESCE(%s, lon),
confidence = GREATEST(confidence, 68),
updated_at = NOW(),
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb
WHERE id = %s
""",
(
phone,
website,
place_id,
plat,
plon,
json.dumps({"google_place_id": place_id, "google_enriched": True}),
entity_id,
),
)
if phone:
_upsert_contact(entity_id, None, phone, "google_places", 72, role="sales", name="Google Places")
return bool(phone or website)
pid = place.get("place_id")
def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2: str, territory_code: str, query: str) -> tuple[str, int]:
from app.db import execute_returning
pid = place.get("place_id") or details.get("place_id")
if not pid:
return "skipped", 0
name = details.get("name") or place.get("name") or "Unknown"
geo = details.get("geometry", {}).get("location", place.get("geometry", {}).get("location", {}))
plat = geo.get("lat")
plon = geo.get("lng")
phone = details.get("formatted_phone_number") or details.get("international_phone_number")
website = details.get("website")
entity_type = "contract_caterer" if "catering" in query else "distributor"
entity_type = "contract_caterer" if "catering" in query else "wholesaler"
existing = fetch_one(
"SELECT id FROM export_market_entities WHERE metadata->>'google_place_id' = %s",
"SELECT id FROM export_market_entities WHERE google_place_id = %s",
(pid,),
)
if existing:
execute(
"""
UPDATE export_market_entities SET
phone = COALESCE(%s, phone), website = COALESCE(%s, website),
lat = COALESCE(%s, lat), lon = COALESCE(%s, lon),
confidence = GREATEST(confidence, 65), updated_at = NOW()
WHERE id = %s
""",
(phone, website, plat, plon, existing["id"]),
)
entity_id = existing["id"]
action = "updated"
else:
row = execute_returning(
"""
INSERT INTO export_market_entities (
name, entity_type, country_iso2, territory_code, lat, lon,
phone, website, google_place_id, source, sources, confidence, metadata
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'google_places','["google_places"]'::jsonb,65,%s)
RETURNING id
""",
(
name[:255], entity_type, country_iso2.upper(), territory_code,
plat, plon, phone, website, pid,
json.dumps({"google_place_id": pid}),
),
)
entity_id = row["id"]
action = "inserted"
_apply_place_to_entity(int(existing["id"]), pid, details)
return "updated", int(existing["id"])
row = execute_returning(
"""
INSERT INTO export_market_entities (
name, entity_type, country_iso2, territory_code, lat, lon,
phone, website, google_place_id, source, sources, confidence, metadata
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'google_places','["google_places"]'::jsonb,65,%s)
RETURNING id
""",
(
name[:255],
entity_type,
country_iso2.upper(),
territory_code,
plat,
plon,
phone,
website,
pid,
json.dumps({"google_place_id": pid}),
),
)
entity_id = int(row["id"])
if phone:
_upsert_contact(entity_id, None, phone, "google_places", 70, role="sales", name="Google Places")
return action, entity_id
return "inserted", entity_id
def enrich_entities_places_country(country_iso2: str, limit: int = 400) -> dict[str, Any]:
"""Match existing OSM entities to Google Places for phone/website."""
if not _api_key():
return {"skipped": True, "reason": "GOOGLE_MAPS_API_KEY not set"}
entities = fetch_all(
"""
SELECT id, name, lat, lon, city, phone, website
FROM export_market_entities
WHERE country_iso2 = %s
AND lat IS NOT NULL AND lon IS NOT NULL
AND (phone IS NULL OR phone = '')
AND (google_place_id IS NULL OR google_place_id = '')
ORDER BY confidence DESC NULLS LAST, id
LIMIT %s
""",
(country_iso2.upper(), limit),
)
enriched = 0
not_found = 0
errors = 0
for ent in entities:
query = ent["name"] or ""
if ent.get("city"):
query = f"{query} {ent['city']}"
try:
pid = _find_place_id(query, float(ent["lat"]), float(ent["lon"]))
if not pid:
not_found += 1
time.sleep(0.12)
continue
details = _place_details(pid)
if _apply_place_to_entity(int(ent["id"]), pid, details):
enriched += 1
except Exception:
errors += 1
time.sleep(0.15)
return {
"country": country_iso2.upper(),
"candidates": len(entities),
"enriched": enriched,
"not_found": not_found,
"errors": errors,
}
def sync_places_country(country_iso2: str) -> dict[str, Any]:
@@ -165,12 +333,19 @@ def sync_places_country(country_iso2: str) -> dict[str, Any]:
action, _ = _upsert_place(place, details, iso, territory_code, q)
if action == "inserted":
inserted += 1
else:
elif action == "updated":
updated += 1
time.sleep(0.15)
time.sleep(0.5)
return {"inserted": inserted, "updated": updated, "queries": len(QUERIES), "search_points": len(search_points)}
enrich = enrich_entities_places_country(iso, limit=500)
return {
"inserted": inserted,
"updated": updated,
"queries": len(QUERIES),
"search_points": len(search_points),
"enrich_existing": enrich,
}
def sync_places_countries(countries: list[str]) -> dict[str, Any]:
+223 -35
View File
@@ -25,6 +25,17 @@ TED_RSS_URLS = [
from app.export_territory_seeds import BBOX_ONLY_ISO2
from app.export_intel_sync_progress import (
SyncCancelled,
raise_if_cancelled,
finish_job,
get_active_job,
job_step,
set_active_job,
start_job,
update_job,
)
EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
SKIP_EMAIL_SUFFIX = ("example.com", "sentry.io", "wixpress.com", "png", "jpg", "jpeg")
@@ -140,9 +151,22 @@ def _upsert_osm_entity(
hn = tags.get("addr:housenumber") or ""
address = f"{street} {hn}".strip() or None
city = tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village")
phone = (tags.get("phone") or tags.get("contact:phone") or "")[:64] or None
phone = (
tags.get("phone")
or tags.get("contact:phone")
or tags.get("contact:mobile")
or tags.get("mobile")
or tags.get("phone:mobile")
or ""
)[:64] or None
email = (tags.get("email") or tags.get("contact:email") or "")[:255] or None
website = (tags.get("website") or tags.get("contact:website") or "")[:512] or None
website = (
tags.get("website")
or tags.get("contact:website")
or tags.get("contact:url")
or tags.get("url")
or ""
)[:512] or None
existing = fetch_one(
"SELECT id FROM export_market_entities WHERE metadata->>'osm_external_id' = %s",
@@ -185,7 +209,7 @@ def _upsert_osm_entity(
entity_id = row["id"]
action = "inserted"
if email:
if email or phone:
_upsert_contact(entity_id, email, phone, "osm", 60)
return action, entity_id
@@ -285,6 +309,9 @@ def sync_osm_country(country_iso2: str, kinds: Optional[list[str]] = None) -> di
parts = mapping.get(kind, [])
if not parts:
continue
active = get_active_job()
if active:
job_step(active, message=f"OSM {kind} · {country_iso2}", country=country_iso2, step=kind)
query = _build_country_query(parts, country_iso2, bbox)
elements = _fetch_overpass(query)
ki = {"fetched": len(elements), "inserted": 0, "updated": 0}
@@ -352,6 +379,46 @@ def materialize_caterer_presence(country_iso2: str) -> int:
return created
PHONE_RE = re.compile(
r"(?:tel:|phone|telefoon)[^\d+]{0,12}(\+?[\d\s\(\)\-\.]{8,20})|(\+31[\s\-\.]?(?:\(?\d{2}\)?[\s\-\.]?)?[\d\s\-\.]{6,12})",
re.I,
)
def _normalize_phone(raw: str) -> Optional[str]:
s = re.sub(r"[^\d+]", "", (raw or "").strip())
if len(s) < 8:
return None
if s.startswith("00"):
s = "+" + s[2:]
if s.startswith("0") and not s.startswith("+"):
s = "+31" + s[1:]
return s[:32]
def _scrape_phones(url: str) -> list[str]:
if not url:
return []
if not url.startswith("http"):
url = "https://" + url
try:
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(url, headers={"User-Agent": "FoodlinkkExportIntel/1.0"})
if resp.status_code >= 400:
return []
text = resp.text[:500000]
except Exception:
return []
found: set[str] = set()
for m in PHONE_RE.finditer(text):
raw = (m.group(1) or m.group(2) or "").strip()
norm = _normalize_phone(raw)
if norm:
found.add(norm)
return list(found)[:3]
def _scrape_emails(url: str) -> list[str]:
if not url:
return []
@@ -386,8 +453,25 @@ def enrich_contacts_country(country_iso2: str) -> dict[str, Any]:
)
added = 0
scraped = 0
phones_added = 0
for ent in entities:
emails = _scrape_emails(ent["website"])
site = ent["website"]
emails = _scrape_emails(site)
phones = _scrape_phones(site) if not ent.get("phone") else []
# probe common contact pages (gratis, geen API)
if not emails or not phones:
base = site if site.startswith("http") else ("https://" + site)
base = base.rstrip("/")
for path in ("/contact", "/contact/", "/contact-ons", "/contactus", "/over-ons", "/about"):
try:
emails = list(dict.fromkeys(emails + _scrape_emails(base + path)))
if not ent.get("phone"):
phones = list(dict.fromkeys(phones + _scrape_phones(base + path)))
except Exception:
pass
if emails and (ent.get("phone") or phones):
break
time.sleep(0.15)
scraped += 1
for em in emails:
role = "procurement" if any(x in em for x in ("inkoop", "procurement", "sales", "info", "order")) else "general"
@@ -398,13 +482,27 @@ def enrich_contacts_country(country_iso2: str) -> dict[str, Any]:
if not before:
_upsert_contact(ent["id"], em, None, "website_scrape", 65, role=role, name="Website")
added += 1
for ph in phones:
before_p = fetch_one(
"SELECT id FROM export_entity_contacts WHERE entity_id = %s AND phone = %s",
(ent["id"], ph),
)
if not before_p:
_upsert_contact(ent["id"], None, ph, "website_scrape", 60, role="general", name="Website")
added += 1
phones_added += 1
if not ent.get("email") and emails:
execute(
"UPDATE export_market_entities SET email = %s, updated_at = NOW() WHERE id = %s",
(emails[0], ent["id"]),
)
if not ent.get("phone") and phones:
execute(
"UPDATE export_market_entities SET phone = %s, updated_at = NOW() WHERE id = %s",
(phones[0], ent["id"]),
)
time.sleep(0.3)
return {"entities_scraped": scraped, "contacts_added": added}
return {"entities_scraped": scraped, "contacts_added": added, "phones_added": phones_added}
def sync_ted_tenders(limit: int = 50) -> dict[str, Any]:
@@ -476,46 +574,115 @@ def sync_ted_tenders(limit: int = 50) -> dict[str, Any]:
def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None) -> dict[str, Any]:
"""Full OSM + contacts + Places sync for active territories."""
"""Full OSM + contacts sync for active territories (gratis)."""
iso_list = _countries_for_sync(region_code=region_code, max_priority=max_priority)
label = f"Sync {region_code or 'alle landen'} ({len(iso_list)} landen)"
job_id = start_job("all", label, total=len(iso_list), meta={"region": region_code, "max_priority": max_priority})
report: dict[str, Any] = {
"countries": iso_list,
"country_count": len(iso_list),
"region": region_code,
"max_priority": max_priority,
"steps": {},
"job_id": job_id,
}
try:
update_job(job_id, step="ted", message="TED tenders ophalen…")
report["steps"]["ted"] = sync_ted_tenders(40)
report["steps"]["ted"] = sync_ted_tenders(40)
dist: dict[str, Any] = {}
cater: dict[str, Any] = {}
cust: dict[str, Any] = {}
contacts: dict[str, Any] = {}
for i, c in enumerate(iso_list, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Land {i}/{len(iso_list)}: {c}", country=c, step="osm", current=i, total=len(iso_list))
try:
materialize_caterer_presence(c)
dist[c] = sync_osm_country(c, kinds=["distributors"])
cater[c] = sync_osm_country(c, kinds=["caterers"])
cust[c] = sync_osm_country(c, kinds=["customers"])
contacts[c] = enrich_contacts_country(c)
except Exception as exc:
dist[c] = {"error": str(exc)}
time.sleep(2)
report["steps"]["distributors"] = dist
report["steps"]["caterers"] = cater
report["steps"]["customers"] = cust
report["steps"]["contacts"] = contacts
report["steps"]["google_places"] = {"skipped": True, "reason": "gratis-modus: geen Google Places"}
finish_job(job_id, result={"country_count": len(iso_list), "region": region_code})
except SyncCancelled as exc:
finish_job(job_id, error=str(exc))
report["cancelled"] = True
return report
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return report
def sync_missing_countries(max_priority: int = 3) -> dict[str, Any]:
"""OSM + contacts sync for active territories with zero entities."""
rows = fetch_all(
"""
SELECT t.country_iso2
FROM export_territories t
WHERE t.is_active
AND t.sync_priority <= %s
AND NOT EXISTS (
SELECT 1 FROM export_market_entities e WHERE e.country_iso2 = t.country_iso2
)
GROUP BY t.country_iso2
ORDER BY MIN(t.sync_priority), t.country_iso2
""",
(max_priority,),
)
iso_list = [r["country_iso2"] for r in rows]
job_id = start_job("missing", f"Lege landen ({len(iso_list)})", total=len(iso_list))
report: dict[str, Any] = {"countries": iso_list, "country_count": len(iso_list), "steps": {}, "job_id": job_id}
dist: dict[str, Any] = {}
cater: dict[str, Any] = {}
cust: dict[str, Any] = {}
contacts: dict[str, Any] = {}
for c in iso_list:
try:
materialize_caterer_presence(c)
dist[c] = sync_osm_country(c, kinds=["distributors"])
cater[c] = sync_osm_country(c, kinds=["caterers"])
cust[c] = sync_osm_country(c, kinds=["customers"])
contacts[c] = enrich_contacts_country(c)
except Exception as exc:
dist[c] = {"error": str(exc)}
time.sleep(2)
report["steps"]["distributors"] = dist
report["steps"]["caterers"] = cater
report["steps"]["customers"] = cust
report["steps"]["contacts"] = contacts
from app.export_intel_places import sync_places_countries
report["steps"]["google_places"] = sync_places_countries(iso_list)
try:
for i, c in enumerate(iso_list, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Leeg land {i}/{len(iso_list)}: {c}", country=c, current=i, total=len(iso_list))
try:
materialize_caterer_presence(c)
dist[c] = sync_osm_country(c, kinds=["distributors"])
cater[c] = sync_osm_country(c, kinds=["caterers"])
cust[c] = sync_osm_country(c, kinds=["customers"])
contacts[c] = enrich_contacts_country(c)
except Exception as exc:
dist[c] = {"error": str(exc)}
time.sleep(2)
report["steps"] = {
"distributors": dist,
"caterers": cater,
"customers": cust,
"contacts": contacts,
}
finish_job(job_id, result={"country_count": len(iso_list)})
except SyncCancelled as exc:
finish_job(job_id, error=str(exc))
report["cancelled"] = True
return report
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return report
def sync_world_regions(max_priority: int = 2) -> dict[str, Any]:
"""Sync all target regions sequentially (Europe, MENA, Africa, Americas)."""
regions = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean"]
regions = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean", "asia", "oceania"]
out: dict[str, Any] = {"regions": {}, "totals": {"countries": 0}}
for reg in regions:
countries = _countries_for_sync(region_code=reg, max_priority=max_priority)
@@ -571,13 +738,34 @@ def sync_contacts(country_iso2: Optional[str] = None, region_code: Optional[str]
def sync_customers(country_iso2: Optional[str] = None, region_code: Optional[str] = None) -> dict[str, Any]:
if country_iso2:
return sync_osm_country(country_iso2, kinds=["customers"])
countries = _countries_for_sync(region_code=region_code)
results: dict[str, Any] = {}
for c in countries:
job_id = start_job("customers", f"Horeca {country_iso2.upper()}", total=1, meta={"country": country_iso2.upper()})
try:
results[c] = sync_osm_country(c, kinds=["customers"])
job_step(job_id, message=f"Horeca OSM · {country_iso2.upper()}", country=country_iso2.upper(), current=1, total=1)
result = sync_osm_country(country_iso2, kinds=["customers"])
finish_job(job_id, result=result)
return result
except Exception as exc:
results[c] = {"error": str(exc)}
time.sleep(3)
return {"osm": results}
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
countries = _countries_for_sync(region_code=region_code)
label = f"Horeca {region_code or 'regio'} ({len(countries)} landen)"
job_id = start_job("customers", label, total=len(countries), meta={"region": region_code})
results: dict[str, Any] = {}
try:
for i, c in enumerate(countries, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Horeca {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
try:
results[c] = sync_osm_country(c, kinds=["customers"])
except Exception as exc:
results[c] = {"error": str(exc)}
time.sleep(3)
finish_job(job_id, result={"countries": len(countries), "region": region_code})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return {"osm": results, "job_id": job_id}

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