Compare commits
8 Commits
0d74c146aa
...
c5ea8cbb63
| Author | SHA1 | Date | |
|---|---|---|---|
| c5ea8cbb63 | |||
| 910ba465d7 | |||
| 1dd607b060 | |||
| 22a17ed048 | |||
| 5e43b6b246 | |||
| 62239f0a4e | |||
| 5dd69a1138 | |||
| f21d58483b |
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Cockpit user accounts: password hashing, CRUD, permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
# Nav / feature keys shown in Settings → Gebruikers
|
||||
PAGE_CATALOG: list[dict[str, str]] = [
|
||||
{"key": "dashboard", "label": "Overzicht (CEO)"},
|
||||
{"key": "revenue", "label": "Revenue Cockpit"},
|
||||
{"key": "retail", "label": "Retail NL"},
|
||||
{"key": "marketing", "label": "Marketing"},
|
||||
{"key": "packaging", "label": "Packaging"},
|
||||
{"key": "beurs", "label": "Beurs Live"},
|
||||
{"key": "documents", "label": "Documenten"},
|
||||
{"key": "projects", "label": "Projecten"},
|
||||
{"key": "hermes", "label": "Telegram / Hermes"},
|
||||
{"key": "analytics", "label": "Analytics"},
|
||||
{"key": "export", "label": "Wereldexport"},
|
||||
{"key": "clients", "label": "Klanten"},
|
||||
{"key": "deals", "label": "Deals"},
|
||||
{"key": "products", "label": "Producten"},
|
||||
{"key": "suppliers", "label": "Leveranciers"},
|
||||
{"key": "reports", "label": "Rapporten"},
|
||||
{"key": "ops", "label": "IT Ops"},
|
||||
{"key": "agents", "label": "Agents"},
|
||||
{"key": "browser", "label": "Browser"},
|
||||
{"key": "studio", "label": "Studio"},
|
||||
{"key": "voice", "label": "Voice"},
|
||||
{"key": "settings", "label": "Instellingen"},
|
||||
]
|
||||
|
||||
# path prefix → permission key (order: longest prefix first)
|
||||
PATH_RULES: list[tuple[str, str]] = [
|
||||
("/revenue-cockpit", "revenue"),
|
||||
("/export-intel", "export"),
|
||||
("/packaging", "packaging"),
|
||||
("/marketing", "marketing"),
|
||||
("/documents", "documents"),
|
||||
("/analytics", "analytics"),
|
||||
("/suppliers", "suppliers"),
|
||||
("/products", "products"),
|
||||
("/projects", "projects"),
|
||||
("/settings", "settings"),
|
||||
("/clients", "clients"),
|
||||
("/browser", "browser"),
|
||||
("/monitor", "browser"),
|
||||
("/hermes", "hermes"),
|
||||
("/retail", "retail"),
|
||||
("/reports", "reports"),
|
||||
("/studio", "studio"),
|
||||
("/agents", "agents"),
|
||||
("/beurs", "beurs"),
|
||||
("/deals", "deals"),
|
||||
("/voice", "voice"),
|
||||
("/ops", "ops"),
|
||||
("/", "dashboard"),
|
||||
]
|
||||
|
||||
ADMIN_SEED_USERNAME = "aissa"
|
||||
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
rounds = 200_000
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
|
||||
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
algo, rounds_s, salt, digest = password_hash.split("$", 3)
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
rounds = int(rounds_s)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
|
||||
return secrets.compare_digest(dk.hex(), digest)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_perms(raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {"pages": [], "manage_users": False}
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
return {"pages": [], "manage_users": False}
|
||||
if not isinstance(raw, dict):
|
||||
return {"pages": [], "manage_users": False}
|
||||
pages = raw.get("pages") or []
|
||||
if not isinstance(pages, list):
|
||||
pages = []
|
||||
return {
|
||||
"pages": [str(p) for p in pages],
|
||||
"manage_users": bool(raw.get("manage_users")),
|
||||
}
|
||||
|
||||
|
||||
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
perms = _normalize_perms(row.get("permissions"))
|
||||
role = row.get("role") or "user"
|
||||
if role == "admin":
|
||||
perms = {
|
||||
"pages": [p["key"] for p in PAGE_CATALOG],
|
||||
"manage_users": True,
|
||||
}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"display_name": row.get("display_name") or row["username"],
|
||||
"role": role,
|
||||
"is_active": bool(row.get("is_active", True)),
|
||||
"avatar_url": row.get("avatar_url") or None,
|
||||
"permissions": perms,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
|
||||
}
|
||||
|
||||
|
||||
def ensure_admin_seed() -> None:
|
||||
"""Create default admin aissa if table empty / user missing."""
|
||||
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
|
||||
if row:
|
||||
return
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
|
||||
VALUES (%s, %s, %s, 'admin', %s::jsonb)
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
""",
|
||||
(
|
||||
ADMIN_SEED_USERNAME,
|
||||
hash_password(ADMIN_SEED_PASSWORD),
|
||||
"Aissa",
|
||||
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one(
|
||||
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
|
||||
(username.strip(),),
|
||||
)
|
||||
if not row or not row.get("is_active"):
|
||||
return None
|
||||
if not verify_password(password, row["password_hash"]):
|
||||
return None
|
||||
execute(
|
||||
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
|
||||
(row["id"],),
|
||||
)
|
||||
return public_user(row)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
|
||||
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
return public_user(row)
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
|
||||
return [public_user(r) for r in rows if r]
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str | None,
|
||||
role: str,
|
||||
permissions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
|
||||
if role not in ("admin", "user"):
|
||||
role = "user"
|
||||
perms = _normalize_perms(permissions)
|
||||
if role == "admin":
|
||||
perms["manage_users"] = True
|
||||
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING *
|
||||
""",
|
||||
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
|
||||
)
|
||||
return public_user(row) # type: ignore
|
||||
|
||||
|
||||
def update_user(
|
||||
user_id: int,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
new_name = display_name if display_name is not None else row.get("display_name")
|
||||
new_role = role if role in ("admin", "user") else row["role"]
|
||||
new_active = row["is_active"] if is_active is None else bool(is_active)
|
||||
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
|
||||
if new_role == "admin":
|
||||
new_perms["manage_users"] = True
|
||||
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
|
||||
pw_hash = row["password_hash"]
|
||||
if password:
|
||||
pw_hash = hash_password(password)
|
||||
updated = fetch_one(
|
||||
"""
|
||||
UPDATE cockpit_users
|
||||
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
|
||||
password_hash = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
|
||||
)
|
||||
return public_user(updated) # type: ignore
|
||||
|
||||
|
||||
def delete_user(user_id: int, *, actor_id: int) -> None:
|
||||
if user_id == actor_id:
|
||||
raise ValueError("Je kunt jezelf niet verwijderen")
|
||||
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
if row["role"] == "admin":
|
||||
admins = fetch_one(
|
||||
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
|
||||
)
|
||||
if admins and admins["n"] <= 1:
|
||||
raise ValueError("Laatste admin kan niet verwijderd worden")
|
||||
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
pages = set((user.get("permissions") or {}).get("pages") or [])
|
||||
# normalize
|
||||
p = path.split("?")[0] or "/"
|
||||
if p != "/" and p.endswith("/"):
|
||||
p = p.rstrip("/")
|
||||
for prefix, key in PATH_RULES:
|
||||
if prefix == "/":
|
||||
if p == "/" or p == "":
|
||||
return "dashboard" in pages
|
||||
continue
|
||||
if p == prefix or p.startswith(prefix + "/"):
|
||||
return key in pages
|
||||
return "dashboard" in pages
|
||||
|
||||
|
||||
def can_manage_users(user: dict[str, Any] | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
return bool((user.get("permissions") or {}).get("manage_users"))
|
||||
|
||||
|
||||
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
UPDATE cockpit_users
|
||||
SET avatar_url = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(avatar_url, user_id),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("Gebruiker niet gevonden")
|
||||
return public_user(row) # type: ignore
|
||||
@@ -10,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
|
||||
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
|
||||
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
|
||||
|
||||
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
|
||||
|
||||
|
||||
def slugify(name: str, max_len: int = 40) -> str:
|
||||
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
|
||||
return (s[:max_len] or "file")
|
||||
|
||||
|
||||
def root() -> Path:
|
||||
return AGENTS_NAS_ROOT
|
||||
|
||||
|
||||
def unc() -> str:
|
||||
return AGENTS_NAS_UNC
|
||||
|
||||
|
||||
def smb() -> str:
|
||||
return AGENTS_NAS_SMB
|
||||
|
||||
|
||||
def ensure_root() -> Path:
|
||||
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
sys_dir = AGENTS_NAS_ROOT / "_system"
|
||||
sys_dir.mkdir(parents=True, exist_ok=True)
|
||||
readme = sys_dir / "README.txt"
|
||||
if not readme.exists():
|
||||
readme.write_text(
|
||||
"Foodlinkk Agents NAS workspace\n"
|
||||
f"Pad: {AGENTS_NAS_SMB}\n"
|
||||
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
|
||||
"Submappen: notes, exports, memory, inbox, outbox\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
meta = {
|
||||
"unc": AGENTS_NAS_UNC,
|
||||
"smb": AGENTS_NAS_SMB,
|
||||
"container_path": str(AGENTS_NAS_ROOT),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
(sys_dir / "workspace.json").write_text(
|
||||
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return AGENTS_NAS_ROOT
|
||||
|
||||
|
||||
def ensure_agent_folder(agent_key: str) -> Path:
|
||||
ensure_root()
|
||||
key = slugify(agent_key or "unknown", 32).lower()
|
||||
base = AGENTS_NAS_ROOT / key
|
||||
for sub in AGENT_SUBDIRS:
|
||||
(base / sub).mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"agent_key": key,
|
||||
"path": str(base),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}",
|
||||
"subdirs": list(AGENT_SUBDIRS),
|
||||
}
|
||||
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return base
|
||||
|
||||
|
||||
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
|
||||
ensure_root()
|
||||
keys = agent_keys or []
|
||||
created = []
|
||||
for k in keys:
|
||||
p = ensure_agent_folder(str(k))
|
||||
created.append(str(p))
|
||||
return {
|
||||
"ok": True,
|
||||
"root": str(AGENTS_NAS_ROOT),
|
||||
"smb": AGENTS_NAS_SMB,
|
||||
"agents": created,
|
||||
"count": len(created),
|
||||
}
|
||||
|
||||
|
||||
def memory_rule_text() -> str:
|
||||
return (
|
||||
"NAS DATA (HARDCODED — VERPLICHT):\n"
|
||||
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
|
||||
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
|
||||
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
|
||||
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
|
||||
"inbox/ (inkomend), outbox/ (uitgaand).\n"
|
||||
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
|
||||
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
|
||||
)
|
||||
|
||||
|
||||
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
|
||||
base = ensure_agent_folder(agent_key)
|
||||
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
|
||||
rel = relative_path.lstrip("/\\").replace("..", "")
|
||||
if not rel:
|
||||
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
|
||||
target = (base / sub / rel).resolve()
|
||||
if not str(target).startswith(str(base.resolve())):
|
||||
raise ValueError("Pad buiten agent-map niet toegestaan")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
|
||||
key = slugify(agent_key).lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(target),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
|
||||
}
|
||||
|
||||
|
||||
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
|
||||
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
|
||||
return write_text(
|
||||
agent_key,
|
||||
name,
|
||||
json.dumps(data, indent=2, ensure_ascii=False, default=str),
|
||||
subdir=subdir,
|
||||
)
|
||||
|
||||
|
||||
def agent_paths(agent_key: str) -> dict[str, str]:
|
||||
key = slugify(agent_key or "unknown", 32).lower()
|
||||
return {
|
||||
"root": str(AGENTS_NAS_ROOT / key),
|
||||
"smb": f"{AGENTS_NAS_SMB}/{key}",
|
||||
}
|
||||
@@ -1,49 +1,169 @@
|
||||
"""Agent soul profiles and Herman permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services.agent_names import aliases_for_key, normalize_agent_key
|
||||
|
||||
|
||||
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
||||
if events_6h > 0 and errors_24h == 0:
|
||||
return "healthy"
|
||||
if events_6h > 0:
|
||||
return "warn"
|
||||
if event_count > 0:
|
||||
return "idle"
|
||||
return "offline"
|
||||
|
||||
|
||||
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, title, status, created_at
|
||||
FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not key:
|
||||
continue
|
||||
bucket = by_key.setdefault(
|
||||
key,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] += 1
|
||||
created = r.get("created_at")
|
||||
if bucket["last_event_at"] is None:
|
||||
bucket["last_event_at"] = created
|
||||
bucket["last_event_title"] = r.get("title")
|
||||
bucket["current_status"] = r.get("status")
|
||||
if created is not None:
|
||||
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age_ok = True
|
||||
try:
|
||||
if created >= now - timedelta(hours=6):
|
||||
bucket["events_6h"] += 1
|
||||
if created >= now - timedelta(hours=24):
|
||||
bucket["events_24h"] += 1
|
||||
if str(r.get("status") or "") in ("error", "rejected"):
|
||||
bucket["errors_24h"] += 1
|
||||
except Exception:
|
||||
age_ok = False
|
||||
_ = age_ok
|
||||
# Also count older totals beyond 30d window lightly
|
||||
try:
|
||||
totals = fetch_all(
|
||||
"""
|
||||
SELECT agent_name, COUNT(*)::int AS c
|
||||
FROM agent_events
|
||||
GROUP BY agent_name
|
||||
"""
|
||||
)
|
||||
total_by: dict[str, int] = {}
|
||||
for r in totals:
|
||||
k = normalize_agent_key(str(r.get("agent_name") or ""))
|
||||
if not k:
|
||||
continue
|
||||
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
|
||||
for k, c in total_by.items():
|
||||
bucket = by_key.setdefault(
|
||||
k,
|
||||
{
|
||||
"event_count": 0,
|
||||
"events_6h": 0,
|
||||
"events_24h": 0,
|
||||
"errors_24h": 0,
|
||||
"last_event_at": None,
|
||||
"last_event_title": None,
|
||||
"current_status": None,
|
||||
"current_event_type": None,
|
||||
},
|
||||
)
|
||||
bucket["event_count"] = c
|
||||
except Exception:
|
||||
pass
|
||||
return by_key
|
||||
|
||||
|
||||
def list_souls() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""SELECT s.*,
|
||||
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
|
||||
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
|
||||
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_task,
|
||||
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_status,
|
||||
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_event_type
|
||||
FROM agent_souls s ORDER BY s.display_name"""
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Daily Herman War Room digest → Telegram (Aïssa + Mo)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import llm_router
|
||||
|
||||
log = logging.getLogger("cockpit.warroom_digest")
|
||||
|
||||
|
||||
def _recipients() -> list[tuple[str, str]]:
|
||||
"""(chat_id, display_name)"""
|
||||
return [
|
||||
(str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), "Aïssa"),
|
||||
(str(getattr(settings, "HERMES_CTO_CHAT_ID", None) or "789036463"), "Mo"),
|
||||
]
|
||||
|
||||
|
||||
def collect_warroom_day(*, hours: int = 24, limit: int = 120) -> list[dict[str, Any]]:
|
||||
hours = max(1, min(int(hours), 72))
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT m.id, m.sender_type, m.agent_key, m.username, m.content, m.created_at, r.kind, r.title
|
||||
FROM warroom_messages m
|
||||
JOIN warroom_rooms r ON r.id = m.room_id
|
||||
WHERE m.created_at >= NOW() - make_interval(hours => %s)
|
||||
AND r.kind = 'warroom'
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(hours, limit),
|
||||
)
|
||||
out = []
|
||||
for r in rows or []:
|
||||
item = dict(r)
|
||||
if item.get("created_at") and hasattr(item["created_at"], "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _transcript(messages: list[dict[str, Any]], *, max_chars: int = 6000) -> str:
|
||||
lines: list[str] = []
|
||||
for m in messages:
|
||||
st = m.get("sender_type")
|
||||
if st == "user":
|
||||
who = m.get("username") or "user"
|
||||
elif st == "agent":
|
||||
who = m.get("agent_key") or "agent"
|
||||
else:
|
||||
who = "system"
|
||||
content = (m.get("content") or "").strip().replace("\n", " ")
|
||||
if not content:
|
||||
continue
|
||||
lines.append(f"- {who}: {content[:280]}")
|
||||
text = "\n".join(lines)
|
||||
return text[-max_chars:]
|
||||
|
||||
|
||||
async def generate_digest_text(messages: list[dict[str, Any]]) -> str:
|
||||
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
if not messages:
|
||||
return (
|
||||
f"📋 War Room dagrapport {day}\n\n"
|
||||
"Geen activiteit in #war-room vandaag. Agents stonden klaar; geen overleg gelogd."
|
||||
)
|
||||
transcript = _transcript(messages)
|
||||
system = (
|
||||
"Je bent Herman, Co-CEO van Foodlinkk. Schrijf een beknopte Nederlandse "
|
||||
"Telegram-samenvatting van het War Room-overleg voor Aïssa (CEO) en Mo (CTO).\n"
|
||||
"Structuur:\n"
|
||||
f"1) Kop: 📋 War Room dagrapport {day}\n"
|
||||
"2) 3–6 bullets: wat agents bespraken (pipeline, deals, acties, blockers)\n"
|
||||
"3) Sectie 'Acties / open punten' met concrete next steps\n"
|
||||
"4) 1 zin afsluiting\n"
|
||||
"Max ~350 woorden. Geen @zelf-prefix. Geen 'agents staan uit'."
|
||||
)
|
||||
user = f"War Room transcript (nieuwste onderaan):\n{transcript}"
|
||||
reply, _meta = await llm_router.chat_messages(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
timeout=90.0,
|
||||
user=str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"),
|
||||
max_tokens=900,
|
||||
temperature=0.3,
|
||||
)
|
||||
text = (reply or "").strip()
|
||||
if not text.startswith("📋"):
|
||||
text = f"📋 War Room dagrapport {day}\n\n{text}"
|
||||
return text[:3500]
|
||||
|
||||
|
||||
async def _deliver_telegram(chat_id: str, name: str, digest: str) -> dict[str, Any]:
|
||||
"""Ask Hermes agent to deliver the digest into that Telegram chat."""
|
||||
system = (
|
||||
f"Je bent Herman. Bezorg het volgende War Room-dagrapport NU via Telegram "
|
||||
f"aan {name} (chat_id={chat_id}). Stuur de tekst zo letterlijk mogelijk. "
|
||||
"Bevestig kort of verzonden."
|
||||
)
|
||||
try:
|
||||
reply, meta = await llm_router.chat_messages(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": digest},
|
||||
],
|
||||
timeout=120.0,
|
||||
user=str(chat_id),
|
||||
max_tokens=200,
|
||||
temperature=0.1,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"chat_id": chat_id,
|
||||
"name": name,
|
||||
"confirm": (reply or "")[:240],
|
||||
"provider": (meta or {}).get("provider_type"),
|
||||
}
|
||||
except Exception as exc:
|
||||
log.exception("warroom digest deliver failed %s", chat_id)
|
||||
return {"ok": False, "chat_id": chat_id, "name": name, "error": str(exc)}
|
||||
|
||||
|
||||
def _persist(digest: str, message_count: int, deliveries: list[dict[str, Any]]) -> None:
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
"herman",
|
||||
"warroom_digest",
|
||||
"warroom_daily_digest",
|
||||
"War Room dagrapport verstuurd",
|
||||
digest[:4000],
|
||||
"completed",
|
||||
"telegram",
|
||||
__import__("json").dumps(
|
||||
{
|
||||
"message_count": message_count,
|
||||
"deliveries": deliveries,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("persist digest event failed")
|
||||
|
||||
try:
|
||||
from app.services import agent_chat
|
||||
|
||||
agent_chat.post_system_line(
|
||||
"📤 Dagrapport verstuurd naar Aïssa + Mo via Telegram.\n\n" + digest[:1500],
|
||||
agent_key="herman",
|
||||
metadata={"kind": "warroom_digest", "deliveries": deliveries},
|
||||
)
|
||||
except Exception:
|
||||
log.exception("post digest to warroom failed")
|
||||
|
||||
|
||||
async def run_daily_digest(*, hours: int = 24, deliver: bool = True) -> dict[str, Any]:
|
||||
messages = collect_warroom_day(hours=hours)
|
||||
digest = await generate_digest_text(messages)
|
||||
deliveries: list[dict[str, Any]] = []
|
||||
if deliver:
|
||||
for chat_id, name in _recipients():
|
||||
deliveries.append(await _deliver_telegram(chat_id, name, digest))
|
||||
_persist(digest, len(messages), deliveries)
|
||||
return {
|
||||
"ok": True,
|
||||
"message_count": len(messages),
|
||||
"digest": digest,
|
||||
"deliveries": deliveries,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:8643 {
|
||||
tls /certs/cert.pem /certs/key.pem
|
||||
encode gzip
|
||||
reverse_proxy cockpit:8600
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
|
||||
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
|
||||
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
|
||||
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
|
||||
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
|
||||
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
|
||||
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
|
||||
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
|
||||
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
|
||||
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
|
||||
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
|
||||
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
|
||||
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
|
||||
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
|
||||
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
|
||||
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
|
||||
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
|
||||
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
|
||||
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
|
||||
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
|
||||
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
|
||||
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
|
||||
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
|
||||
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
|
||||
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
|
||||
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
|
||||
c9ibkVARig==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD
|
||||
DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq
|
||||
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ
|
||||
MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP
|
||||
0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL
|
||||
45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f
|
||||
stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3
|
||||
By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg
|
||||
MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw
|
||||
hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs
|
||||
MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d
|
||||
KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN
|
||||
L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2
|
||||
kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC
|
||||
9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV
|
||||
KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x
|
||||
p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/
|
||||
BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV
|
||||
BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag
|
||||
3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg
|
||||
8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u
|
||||
k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64
|
||||
HJOEFqjE6LCTsSnqN2da2GGm5AY=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
prompt = no
|
||||
[req_distinguished_name]
|
||||
CN = 10.4.7.18
|
||||
O = Foodlinkk Cockpit
|
||||
[v3_req]
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = @alt_names
|
||||
[alt_names]
|
||||
IP.1 = 10.4.7.18
|
||||
IP.2 = 127.0.0.1
|
||||
DNS.1 = localhost
|
||||
DNS.2 = cockpit.foodlinkk.local
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
|
||||
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
|
||||
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
|
||||
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
|
||||
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
|
||||
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
|
||||
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
|
||||
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
|
||||
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
|
||||
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
|
||||
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
|
||||
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
|
||||
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
|
||||
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
|
||||
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
|
||||
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
|
||||
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
|
||||
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
|
||||
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
|
||||
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
|
||||
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
|
||||
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
|
||||
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
|
||||
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
|
||||
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
|
||||
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
|
||||
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
|
||||
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
|
||||
c9ibkVARig==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -138,6 +138,12 @@
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1200;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.agent-events-backdrop.is-open {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.agent-events-drawer {
|
||||
position: fixed;
|
||||
@@ -208,3 +214,111 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
|
||||
|
||||
/* Live working animations */
|
||||
.agent-soul-card {
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(12, 18, 28, 0.85);
|
||||
}
|
||||
.agent-soul-card.is-working {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(56,189,248,0.2), 0 12px 28px rgba(0, 180, 255, 0.12);
|
||||
animation: agent-card-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy {
|
||||
border-color: rgba(251, 191, 36, 0.65);
|
||||
box-shadow: 0 0 0 1px rgba(251,191,36,0.25), 0 12px 28px rgba(251, 191, 36, 0.12);
|
||||
animation: agent-card-pulse-busy 1.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap,
|
||||
.agent-soul-card.is-busy .agent-char-wrap {
|
||||
animation: agent-avatar-bob 1.1s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-working .agent-char-wrap::after,
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 70% 20%, rgba(56,189,248,0.35), transparent 55%);
|
||||
animation: agent-glow-sweep 2.4s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-char-wrap::after {
|
||||
background: radial-gradient(circle at 70% 20%, rgba(251,191,36,0.4), transparent 55%);
|
||||
}
|
||||
.agent-activity-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 12px;
|
||||
margin-left: 0.35rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.agent-activity-eq span {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #38bdf8;
|
||||
animation: agent-eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.agent-soul-card.is-busy .agent-activity-eq span { background: #fbbf24; }
|
||||
.agent-activity-eq span:nth-child(1) { height: 4px; animation-delay: 0s; }
|
||||
.agent-activity-eq span:nth-child(2) { height: 8px; animation-delay: 0.12s; }
|
||||
.agent-activity-eq span:nth-child(3) { height: 6px; animation-delay: 0.24s; }
|
||||
.agent-activity-eq span:nth-child(4) { height: 10px; animation-delay: 0.08s; }
|
||||
|
||||
.agent-status-pill.active {
|
||||
border-color: rgba(34,197,94,0.45);
|
||||
color: #86efac;
|
||||
background: rgba(34,197,94,0.1);
|
||||
}
|
||||
.agent-status-pill.busy {
|
||||
border-color: rgba(245,158,11,0.5);
|
||||
color: #fcd34d;
|
||||
background: rgba(245,158,11,0.12);
|
||||
animation: agent-pill-blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
.agent-status-pill.idle .dot { background: #64748b; }
|
||||
.agent-status-pill.offline .dot { background: #475569; opacity: 0.7; }
|
||||
.agent-status-pill.offline { color: #64748b; }
|
||||
|
||||
@keyframes agent-card-pulse {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
@keyframes agent-card-pulse-busy {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-3px) scale(1.01); }
|
||||
}
|
||||
@keyframes agent-avatar-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
@keyframes agent-glow-sweep {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
@keyframes agent-eq {
|
||||
0%, 100% { transform: scaleY(0.55); opacity: 0.7; }
|
||||
50% { transform: scaleY(1.25); opacity: 1; }
|
||||
}
|
||||
@keyframes agent-pill-blink {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||||
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
|
||||
}
|
||||
|
||||
/* Brighter, readable agent avatars on dark UI */
|
||||
.agent-char-wrap {
|
||||
background: linear-gradient(145deg, #1e4a6e, #163a58) !important;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35) !important;
|
||||
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
.agent-char-wrap .agent-char-svg {
|
||||
filter: brightness(1.18) contrast(1.08) saturate(1.15);
|
||||
}
|
||||
.agent-soul-card .agent-char-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -216,3 +216,162 @@
|
||||
.dot-in { background: var(--hm-cyan); }
|
||||
.dot-out { background: var(--hm-purple); }
|
||||
.dot-edge { background: var(--hm-orange); }
|
||||
|
||||
|
||||
.hm-section-title {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
}
|
||||
.hm-bucket-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.hm-bucket-card {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
|
||||
.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; }
|
||||
.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
|
||||
.hm-tool-list li {
|
||||
display: flex; justify-content: space-between; gap: 0.75rem;
|
||||
padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
font-size: 0.78rem; color: #cbd5e1;
|
||||
}
|
||||
.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; }
|
||||
.hm-tool-list-tall { max-height: 280px; overflow: auto; }
|
||||
.status-badge.on { color: #b8ff3c; }
|
||||
.status-badge.off { color: #f87171; }
|
||||
|
||||
/* Clickable buckets + explorer modal */
|
||||
.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; }
|
||||
.hm-bucket-click {
|
||||
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||
transition: border-color .15s, transform .12s, background .15s;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.hm-bucket-click:hover {
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hm-bucket-open {
|
||||
margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75;
|
||||
}
|
||||
.hm-bucket-click:hover .hm-bucket-open { opacity: 1; }
|
||||
|
||||
.hm-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(2, 6, 14, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.hm-modal {
|
||||
width: min(1100px, 96vw);
|
||||
height: min(820px, 92vh);
|
||||
background: #0b1220;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.55);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-modal-head {
|
||||
display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start;
|
||||
padding: 1rem 1.1rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
|
||||
.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.hm-modal-close {
|
||||
background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem;
|
||||
}
|
||||
.hm-modal-close:hover { color: #fff; }
|
||||
.hm-chip {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: rgba(15,23,42,0.7);
|
||||
color: #cbd5e1; border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer;
|
||||
}
|
||||
.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); }
|
||||
|
||||
.hm-modal-tabs {
|
||||
display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
}
|
||||
.hm-modal-tabs button {
|
||||
background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem;
|
||||
font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; }
|
||||
|
||||
.hm-modal-body { flex: 1; min-height: 0; }
|
||||
.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; }
|
||||
.hm-modal-side {
|
||||
border-right: 1px solid rgba(148,163,184,0.12);
|
||||
overflow: auto; background: rgba(8, 12, 20, 0.9);
|
||||
position: relative;
|
||||
}
|
||||
.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; }
|
||||
.hm-side-loading {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8;
|
||||
background: rgba(14,165,233,0.08);
|
||||
}
|
||||
.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; }
|
||||
.hm-event-item, .hm-file-item {
|
||||
width: 100%; text-align: left; cursor: pointer;
|
||||
background: transparent; border: 1px solid transparent; border-radius: 8px;
|
||||
padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem;
|
||||
}
|
||||
.hm-event-item:hover, .hm-file-item:hover,
|
||||
.hm-event-item.active, .hm-file-item.active {
|
||||
background: rgba(14,165,233,0.1);
|
||||
border-color: rgba(56,189,248,0.25);
|
||||
}
|
||||
.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||
.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; }
|
||||
.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; }
|
||||
.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; }
|
||||
.hm-event-preview, .hm-file-path {
|
||||
font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.hm-file-name { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; }
|
||||
.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; }
|
||||
.hm-explorer-bar {
|
||||
display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;
|
||||
padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
position: sticky; top: 0; background: #0b1220; z-index: 1;
|
||||
}
|
||||
.hm-explorer-path {
|
||||
font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
|
||||
}
|
||||
.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
|
||||
.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; }
|
||||
.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; }
|
||||
.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; }
|
||||
.hm-preview-code {
|
||||
margin: 0; padding: 0.85rem; border-radius: 10px;
|
||||
background: #020617; border: 1px solid rgba(148,163,184,0.15);
|
||||
color: #e2e8f0; font-size: 0.72rem; line-height: 1.45;
|
||||
white-space: pre-wrap; word-break: break-word; max-height: none;
|
||||
}
|
||||
.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; }
|
||||
.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
|
||||
.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hm-modal { height: 94vh; }
|
||||
.hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; }
|
||||
.hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
:root {
|
||||
--bg0: #06090e;
|
||||
--bg1: #0c121b;
|
||||
--card: #111821;
|
||||
--line: rgba(148, 163, 184, 0.18);
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--gold: #fbbf24;
|
||||
--green: #22c55e;
|
||||
--danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
.login-body {
|
||||
min-height: 100%;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(900px 420px at 15% -10%, rgba(251, 191, 36, 0.14), transparent 55%),
|
||||
radial-gradient(700px 380px at 100% 10%, rgba(34, 197, 94, 0.1), transparent 50%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1) 55%, #0a1018);
|
||||
}
|
||||
.login-shell {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: max(1.25rem, env(safe-area-inset-top)) 1rem max(1.25rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
background: rgba(17, 24, 33, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 1.75rem 1.5rem 1.35rem;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.login-logo {
|
||||
display: block;
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
.login-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.login-error {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.login-form { display: grid; gap: 0.85rem; }
|
||||
.login-label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
.login-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(251, 191, 36, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
.login-btn {
|
||||
margin-top: 0.35rem;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-btn:hover { filter: brightness(1.05); }
|
||||
.login-foot {
|
||||
margin: 1.1rem 0 0;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
+323
-16
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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
@@ -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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '© OSM © 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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+380
-406
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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(); },
|
||||
|
||||
@@ -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
@@ -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 & 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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}';
|
||||
@@ -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/…';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+45
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user