Platform bundle: marketing publish, IT ops, packaging, agents mesh.

Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
Aissa
2026-06-09 00:41:27 +00:00
commit 5d60d33db1
212 changed files with 30044 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY static ./static
COPY templates ./templates
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8600"]
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div x-data="agentsPage()">
<header class="page-header"><h1>Agents</h1><p class="subtitle page-tip">Marketing agent rules, event log, and Herman task delegation.</p></header>
<section class="panel"><h2>Delegate to Herman</h2>
<form class="form-grid" @submit.prevent="delegate">
<input class="form-input" x-model="delegateForm.agent" placeholder="Agent key (marketing, bizdev...)" />
<textarea class="form-input" x-model="delegateForm.task" placeholder="Task"></textarea>
<button class="btn btn-primary" type="submit">Send</button>
</form>
<pre x-text="delegateResult"></pre></section>
<section class="panel"><h2>Agent rules</h2>
<button class="btn btn-secondary" @click="loadRules()">Reload rules</button>
<pre x-text="rulesJson"></pre></section>
<section class="panel"><h2>Event log</h2>
<input class="form-input" x-model="filter" placeholder="Filter text..." />
<ul class="agent-feed">{% for ev in events %}
<li class="clickable-row" x-show="!filter || '{{ (ev.title or ev.event_type)|e }}'.toLowerCase().includes(filter.toLowerCase())">
<span class="badge">{{ ev.agent_name }}</span> {{ ev.title or ev.event_type }} <small>{{ ev.created_at }}</small>
</li>{% endfor %}</ul></section>
</div>
{% endblock %}
{% block scripts %}
<script>
function agentsPage() {
return {
filter: '', rulesJson: '', delegateResult: '',
delegateForm: { agent: 'marketing', task: '' },
async delegate() {
const r = await Cockpit.api('/delegate', { method: 'POST', body: JSON.stringify(this.delegateForm) });
this.delegateResult = JSON.stringify(r.result, null, 2);
},
async loadRules() {
const r = await Cockpit.api('/agent-rules');
this.rulesJson = JSON.stringify(r.items, null, 2);
}
};
}
</script>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
router = APIRouter(prefix="/agents", tags=["agents"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AGENT_STATUSES = [
{"name": "Herman", "role": "CEO Briefing", "color": "cyan"},
{"name": "Sales", "role": "Pipeline", "color": "purple"},
{"name": "Marketing", "role": "Social & Content", "color": "amber"},
{"name": "Ops", "role": "Operations", "color": "green"},
]
@router.get("")
async def agents_page(request: Request):
events: list = []
try:
events = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events
ORDER BY created_at DESC
LIMIT 100
"""
)
for ev in events:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
events = []
return templates.TemplateResponse(
"agents.html",
{
"request": request,
"page_title": "Agents",
"agents": AGENT_STATUSES,
"events": events,
},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'approved'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
@router.post("/reject/{event_id}")
async def reject_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'rejected'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
View File
+24
View File
@@ -0,0 +1,24 @@
import os
class Settings:
DB_HOST: str = os.getenv("DB_HOST", "postgres")
DB_PORT: int = int(os.getenv("DB_PORT", "5432"))
DB_USER: str = os.getenv("DB_USER", "aissa")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Foodlinkk#2026")
DB_NAME: str = os.getenv("DB_NAME", "foodlinkk")
OLLAMA_URL: str = os.getenv("OLLAMA_URL", "http://10.4.7.19:11434")
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")
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")
@property
def database_dsn(self) -> str:
return (
f"host={self.DB_HOST} port={self.DB_PORT} dbname={self.DB_NAME} "
f"user={self.DB_USER} password={self.DB_PASSWORD}"
)
settings = Settings()
+65
View File
@@ -0,0 +1,65 @@
from contextlib import contextmanager
from typing import Any, Optional
import psycopg2
from psycopg2 import pool
from psycopg2.extras import RealDictCursor
from app.config import settings
_connection_pool: Optional[pool.SimpleConnectionPool] = None
def init_pool(minconn: int = 1, maxconn: int = 10) -> None:
global _connection_pool
if _connection_pool is None:
_connection_pool = pool.SimpleConnectionPool(
minconn,
maxconn,
dsn=settings.database_dsn,
)
def close_pool() -> None:
global _connection_pool
if _connection_pool is not None:
_connection_pool.closeall()
_connection_pool = None
@contextmanager
def get_connection():
if _connection_pool is None:
init_pool()
conn = _connection_pool.getconn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
_connection_pool.putconn(conn)
def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]:
with get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, params)
rows = cur.fetchall()
return [dict(row) for row in rows]
def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]:
with get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, params)
row = cur.fetchone()
return dict(row) if row else None
def execute(query: str, params: Optional[tuple] = None) -> int:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(query, params)
return cur.rowcount
+134
View File
@@ -0,0 +1,134 @@
import asyncio
import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.db import close_pool, fetch_all, init_pool
from app.routes import (
retail,
agents,
analytics,
documents,
api,
clients,
dashboard,
deals,
herman,
studio,
marketing,
monitor,
products,
reports,
suppliers,
voice,
settings,
browser,
hermes,
beurs,
packaging,
ops,
ops_api,
)
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes.agents_api import router as agents_api_router
from app.routes.marketing_api import router as marketing_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
beurs.router,
agents.router,
marketing.router,
retail.router,
clients.router,
deals.router,
products.router,
suppliers.router,
monitor.router,
analytics.router,
documents.router,
reports.router,
voice.router,
settings.router,
browser.router,
herman.router,
studio.router,
hermes.router,
packaging.router,
ops.router,
api.router,
ops_api.router,
admin_router,
ai_router,
herman_api,
voice_api,
settings_router,
agents_api_router,
marketing_api_router,
):
app.include_router(r)
@app.on_event("startup")
def on_startup() -> None:
init_pool()
@app.on_event("shutdown")
def on_shutdown() -> None:
close_pool()
@app.websocket("/ws/agents")
async def ws_agents(websocket: WebSocket) -> None:
await websocket.accept()
last_payload: str | None = None
try:
while True:
try:
rows = fetch_all(
"""SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 50"""
)
for row in rows:
if row.get("created_at") is not None:
row["created_at"] = row["created_at"].isoformat()
payload = json.dumps({"events": rows})
except Exception as exc:
payload = json.dumps({"error": str(exc), "events": []})
if payload != last_payload:
await websocket.send_text(payload)
last_payload = payload
await asyncio.sleep(3)
except WebSocketDisconnect:
return
@app.websocket("/ws/feed")
async def ws_feed(websocket: WebSocket) -> None:
await websocket.accept()
try:
while True:
snapshot = {"type": "heartbeat", "events": []}
try:
snapshot["events"] = fetch_all(
"SELECT agent_name, event_type, title, status, created_at FROM agent_events ORDER BY created_at DESC LIMIT 15"
)
for row in snapshot["events"]:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
except Exception as exc:
snapshot["error"] = str(exc)
await websocket.send_text(json.dumps(snapshot))
await asyncio.sleep(5)
except WebSocketDisconnect:
return
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
router = APIRouter(prefix="/agents", tags=["agents"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AGENT_STATUSES = [
{"name": "Herman", "role": "CEO Briefing", "color": "cyan"},
{"name": "Sales", "role": "Pipeline", "color": "purple"},
{"name": "Marketing", "role": "Social & Content", "color": "amber"},
{"name": "Ops", "role": "Operations", "color": "green"},
]
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh"}:
active_tab = "souls"
events: list = []
try:
events = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events
ORDER BY created_at DESC
LIMIT 100
"""
)
for ev in events:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
events = []
return templates.TemplateResponse(
"agents.html",
{
"request": request,
"page_title": "Agents",
"active_tab": active_tab,
"agents": AGENT_STATUSES,
"events": events,
},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'approved'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
@router.post("/reject/{event_id}")
async def reject_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'rejected'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
+95
View File
@@ -0,0 +1,95 @@
"""Agents API — souls & activity."""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import fetch_all
from app.services import agent_souls
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
class SoulUpdate(BaseModel):
display_name: Optional[str] = None
role_title: Optional[str] = None
soul_md: Optional[str] = None
responsibilities: Optional[str] = None
is_active: Optional[bool] = None
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@router.get("/souls/{agent_key}")
def api_get_soul(agent_key: str) -> dict[str, Any]:
soul = agent_souls.get_soul(agent_key)
if not soul:
raise HTTPException(404, "Agent not found")
return {"soul": soul}
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True))
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"soul": soul}
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
stats_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS agent_key,
MAX(created_at) AS last_event_at,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
COUNT(*) FILTER (
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND status IN ('error', 'rejected')
) AS errors_24h
FROM agent_events
GROUP BY LOWER(agent_name)
"""
)
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
nodes: list[dict[str, Any]] = []
for soul in souls:
key = str(soul.get("agent_key") or "").lower()
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
health = "offline"
if events_6h > 0 and errors_24h == 0:
health = "healthy"
elif events_6h > 0:
health = "warn"
elif int(soul.get("event_count") or 0) > 0:
health = "idle"
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
node["last_event_at"] = row["last_event_at"].isoformat()
nodes.append(node)
edge_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND LOWER(agent_name) <> 'herman'
GROUP BY LOWER(agent_name)
ORDER BY weight DESC
"""
)
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
return {"nodes": nodes, "edges": edges}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<header class="page-header"><h1>Analytics</h1><p class="subtitle page-tip">Pipeline, clients, and agent activity from live database counts.</p></header>
<div class="kpi-grid">
<a href="/deals" class="kpi-card clickable-kpi"><div class="kpi-label">Pipeline EUR</div><div class="kpi-value">€{{ "%.0f"|format(metrics.pipeline) }}</div></a>
<a href="/clients" class="kpi-card purple clickable-kpi"><div class="kpi-label">Clients</div><div class="kpi-value">{{ metrics.clients }}</div></a>
</div>
<div class="grid-2">
<section class="panel"><h2>Deals by stage</h2>
<table class="data-table"><thead><tr><th>Stage</th><th>Count</th><th>Total</th></tr></thead>
<tbody>{% for row in metrics.deals_by_stage %}<tr><td>{{ row.stage }}</td><td>{{ row.cnt }}</td><td>€{{ row.total }}</td></tr>{% endfor %}</tbody></table>
</section>
<section class="panel"><h2>Events by agent</h2>
<table class="data-table"><thead><tr><th>Agent</th><th>Events</th></tr></thead>
<tbody>{% for row in metrics.events_by_agent %}<tr><td>{{ row.agent_name }}</td><td>{{ row.cnt }}</td></tr>{% endfor %}</tbody></table>
</section>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from app.services.analytics_data import collect_analytics
router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("")
async def analytics_page(request: Request):
initial = collect_analytics({})
return templates.TemplateResponse(
"analytics.html",
{"request": request, "page_title": "Analytics", "initial_data": initial},
)
@router.get("/api/data")
async def analytics_api(
chain: Optional[str] = None,
province: Optional[str] = None,
stage: Optional[str] = None,
agent: Optional[str] = None,
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))
+123
View File
@@ -0,0 +1,123 @@
from datetime import date, datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats
router = APIRouter(prefix="/api", tags=["api"])
def _stats_payload() -> dict[str, Any]:
stats: dict[str, Any] = {
"deals": 0,
"clients": 0,
"pending_approvals": 0,
"pipeline_value": 0,
}
try:
row = fetch_one("SELECT COUNT(*) AS c FROM deals")
stats["deals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM clients")
stats["clients"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
stats["pending_approvals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one(
"SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
)
stats["pipeline_value"] = float(row["total"]) if row else 0
except Exception:
pass
return stats
@router.get("/server-time")
async def server_time():
now = datetime.utcnow()
return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"}
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
bookmarks = []
bookmark_map = {}
try:
bookmarks = fetch_all(
"""SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at
FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30"""
)
for b in bookmarks:
if b.get("created_at") and hasattr(b["created_at"], "isoformat"):
b["created_at"] = b["created_at"].isoformat()
bookmark_map[b["rss_item_id"]] = True
except Exception:
bookmarks = []
stats["rss_bookmarks"] = bookmarks
stats["rss_bookmark_ids"] = list(bookmark_map.keys())
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
@router.post("/herman/briefing")
async def herman_briefing():
try:
content, stats = await generate_daily_briefing()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
@router.get("/herman/briefing/latest")
async def herman_briefing_latest():
try:
row = fetch_one(
"SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
live_stats = serialize_stats(collect_briefing_data())
if not row:
return {"ok": True, "content": None, "stats": live_stats}
if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
row["created_at"] = row["created_at"].isoformat()
return {"ok": True, **row, "stats": live_stats}
@router.get("/live/platform")
async def live_platform(limit: int = 100, agent: Optional[str] = None):
from app.services.platform_live import fetch_platform_events, platform_stats
events = fetch_platform_events(limit=min(limit, 200), agent=agent)
return {"ok": True, "stats": platform_stats(), "events": events}
@router.get("/events")
async def list_events(limit: int = 50):
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
for row in rows:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
return {"events": rows}
+17
View File
@@ -0,0 +1,17 @@
"""Beurs & live market intelligence page."""
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter(tags=["beurs"])
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
@router.get("/beurs")
async def beurs_page(request: Request):
tab = request.query_params.get("tab", "beurs")
return templates.TemplateResponse(
"beurs.html",
{"request": request, "page_title": "Beurs & Live Intel", "initial_tab": tab},
)
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
router = APIRouter(tags=["browser"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
def _monitor_context() -> dict:
sites, changes, logs = [], [], []
stats = {"active_sites": 0, "changes_24h": 0}
try:
sites = _iso_rows(fetch_all(
"""SELECT id, url, name, last_hash, last_crawled, is_active
FROM monitored_sites ORDER BY last_crawled DESC NULLS LAST LIMIT 100"""
))
row = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE")
stats["active_sites"] = int(row["c"]) if row else 0
row = fetch_one(
"SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'"
)
stats["changes_24h"] = int(row["c"]) if row else 0
except Exception:
sites = []
try:
changes = _iso_rows(fetch_all(
"""SELECT pc.id, pc.site_id, pc.old_hash, pc.new_hash, pc.changed_at, ms.url, ms.name
FROM page_changes pc JOIN monitored_sites ms ON pc.site_id = ms.id
ORDER BY pc.changed_at DESC LIMIT 50"""
))
except Exception:
changes = []
try:
logs = _iso_rows(fetch_all(
"""SELECT cl.id, cl.site_id, cl.status, cl.message, cl.logged_at, ms.url, ms.name
FROM crawl_logs cl LEFT JOIN monitored_sites ms ON cl.site_id = ms.id
ORDER BY cl.logged_at DESC LIMIT 50"""
))
except Exception:
logs = []
return {"sites": sites, "page_changes": changes, "crawl_logs": logs, "stats": stats}
@router.get("/browser")
async def browser_page(request: Request):
ctx = _monitor_context()
return templates.TemplateResponse(
"browser.html",
{
"request": request,
"page_title": "Browser & Monitor",
"default_url": "https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/",
"browser_agent_url": "http://10.4.7.18:7790",
"novnc_url": "http://10.4.7.18:6080/vnc.html?autoconnect=true&resize=scale&path=websockify&password=Foodlinkk2026",
"gradio_url": "http://10.4.7.18:7788",
**ctx,
},
)
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div x-data="clientsCrud()">
<header class="page-header"><h1>Clients</h1>
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
<button class="btn btn-primary" @click="openModal()">Add client</button>
<div class="kanban">
{% set stages = ['intake','discovery','proposal','active','churned'] %}
{% for st in stages %}
<div class="kanban-col"><h3>{{ st }}</h3>
{% for c in clients if c.stage == st %}
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
</div>
</div>
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsCrud() {
return {
modal: false, drawer: false, detail: '',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
async save() {
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
location.reload();
},
async setStage(id, stage, name, email, notes) {
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
Cockpit.toast('Stage updated', 'success'); location.reload();
}
};
}
</script>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/clients", tags=["clients"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def clients_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT id, name, contact, email, stage, sector, mrr_estimate, notes, created_at, updated_at
FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("clients.html", {"request": request, "page_title": "Clients", "clients": rows})
+163
View File
@@ -0,0 +1,163 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _safe_count(table: str, where: str = "") -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "") -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def _briefing_payload(briefing: dict | None) -> dict:
"""Always use live DB stats; briefing text may be cached."""
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
if not briefing:
return payload
payload["content"] = briefing.get("content")
payload["created_at"] = briefing.get("created_at")
return payload
@router.get("/")
async def dashboard(request: Request):
kpis = {
"deals_count": _safe_count("deals"),
"clients_count": _safe_count("clients"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
"browser_sessions_24h": 0,
"monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
"supermarkets_count": _safe_count("supermarkets"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
}
briefing = None
try:
briefing = fetch_one(
"SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
if briefing and briefing.get("created_at"):
briefing["created_at"] = briefing["created_at"].isoformat()
except Exception:
briefing = None
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
approvals: list = []
try:
approvals = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at ASC LIMIT 25
"""
)
for ev in approvals:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
approvals = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at,
LEFT(content_text, 300) AS preview
FROM browser_sessions
ORDER BY created_at DESC LIMIT 8
"""
)
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
monitor_sites: list = []
monitor_changes: list = []
try:
monitor_sites = fetch_all(
"""
SELECT id, name, url, last_title, last_crawled, is_active
FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
"""
)
for s in monitor_sites:
if s.get("last_crawled"):
s["last_crawled"] = s["last_crawled"].isoformat()
monitor_changes = fetch_all(
"""
SELECT pc.id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC LIMIT 10
"""
)
for c in monitor_changes:
if c.get("changed_at"):
c["changed_at"] = c["changed_at"].isoformat()
except Exception:
pass
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"page_title": "Herman · Command Center",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
"agent_feed": agent_feed,
"approvals": approvals,
"browser_sessions": browser_sessions,
"monitor_sites": monitor_sites,
"monitor_changes": monitor_changes,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/deals", tags=["deals"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def deals_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT d.id, d.title, d.value, d.stage, d.agent_owner, d.next_action, d.deadline,
d.created_at, c.name AS client_name
FROM deals d LEFT JOIN clients c ON c.id = d.client_id
ORDER BY d.updated_at DESC NULLS LAST LIMIT 200"""
))
except Exception:
rows = []
stages = {}
for r in rows:
st = r.get("stage") or "unknown"
stages.setdefault(st, []).append(r)
return templates.TemplateResponse("deals.html", {"request": request, "page_title": "Deals", "deals": rows, "kanban": stages})
+95
View File
@@ -0,0 +1,95 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
router = APIRouter(prefix="/documents", tags=["documents"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def documents_page(request: Request):
summary = {
"documents": 0,
"total_words": 0,
"unique_words": 0,
"avg_sentiment": 0.0,
"positive": 0,
"neutral": 0,
"negative": 0,
}
try:
row = fetch_one(
"""
SELECT COUNT(*) AS docs,
COALESCE(SUM(word_count), 0) AS words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sent
FROM document_analytics
"""
)
if row:
summary["documents"] = int(row["docs"] or 0)
summary["total_words"] = int(row["words"] or 0)
summary["avg_sentiment"] = round(float(row["avg_sent"] or 0), 3)
row = fetch_one("SELECT COUNT(DISTINCT lemma) AS c FROM document_word_counts WHERE NOT is_stopword")
summary["unique_words"] = int(row["c"] or 0) if row else 0
for label in ("positive", "neutral", "negative"):
row = fetch_one(
"SELECT COUNT(*) AS c FROM document_analytics WHERE sentiment_label = %s",
(label,),
)
summary[label] = int(row["c"] or 0) if row else 0
except Exception:
pass
top_words: list = []
documents: list = []
try:
top_words = fetch_all(
"""
SELECT lemma, MAX(token) AS token, SUM(count) AS total_count,
COUNT(DISTINCT storage_path) AS doc_count
FROM document_word_counts
WHERE NOT is_stopword
GROUP BY lemma
ORDER BY total_count DESC
LIMIT 30
"""
)
documents = _iso_rows(
fetch_all(
"""
SELECT filename, storage_path, doc_type, language, word_count,
unique_lemmas, sentiment_label, sentiment_compound,
sentiment_positive, sentiment_negative, sentiment_neutral,
extraction_method, analyzed_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT 50
"""
)
)
except Exception:
pass
return templates.TemplateResponse(
"documents.html",
{
"request": request,
"page_title": "Documents & Sentiment",
"summary": summary,
"top_words": top_words,
"documents": documents,
},
)
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
from app.services.herman import AGENTS, chat, generate_briefing
router = APIRouter(prefix="/herman", tags=["herman"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def herman_page(request: Request):
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{"request": request, "page_title": "Herman", "agents": AGENTS, "history": history, "last_reply": None, "last_agent": None},
)
@router.post("/chat")
async def herman_chat(request: Request, message: str = Form(...)):
result = await chat(message)
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{
"request": request,
"page_title": "Herman",
"agents": AGENTS,
"history": history,
"last_reply": result.get("reply"),
"last_agent": result.get("agent_label"),
"user_message": message,
},
)
@router.post("/briefing")
async def herman_briefing_page(request: Request):
content = await generate_briefing()
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{
"request": request,
"page_title": "Herman",
"agents": AGENTS,
"history": history,
"last_reply": content,
"last_agent": "Herman · Briefing",
},
)
+52
View File
@@ -0,0 +1,52 @@
"""Hermes Telegram Command Center UI."""
from __future__ import annotations
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app.config import settings
router = APIRouter(prefix="/hermes", tags=["hermes"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
CEO_CHAT_ID = 8859782446
CTO_CHAT_ID = 789036463
CEO_NAME = "Aïssa"
CTO_NAME = "Mo"
@router.get("", response_class=HTMLResponse)
async def hermes_dashboard(request: Request) -> HTMLResponse:
stats = {"conversations": 0, "messages": 0, "edges": 0, "embeddings": 0}
conversations = []
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
if r.status_code == 200:
data = r.json()
stats = data.get("stats") or stats
conversations_resp = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20}
)
if conversations_resp.status_code == 200:
conversations = conversations_resp.json().get("items") or []
except Exception:
pass
return templates.TemplateResponse(
"hermes.html",
{
"request": request,
"page_title": "Hermes",
"stats": stats,
"conversations": conversations,
"ceo_chat_id": CEO_CHAT_ID,
"cto_chat_id": CTO_CHAT_ID,
"allowed_sites": ["Airbnb", "Booking.com", "DuckDuckGo", "HolidayCheck"],
"blocked_sites": ["Google", "Vrbo", "Expedia", "Hotels.com", "TUI", "TripAdvisor"],
},
)
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
from app.services.marketing import evaluate_agent_rules
router = APIRouter(prefix="/marketing", tags=["marketing"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def marketing_page(request: Request):
evaluate_agent_rules()
posts, mentions, accounts, rules, logs = [], [], [], [], []
analytics = {"mention_count": 0, "avg_sentiment": 0}
try:
posts = _iso_rows(fetch_all(
"""SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp
JOIN social_accounts sa ON sp.account_id = sa.id
ORDER BY sp.scheduled_time DESC LIMIT 50"""
))
except Exception:
posts = []
try:
mentions = _iso_rows(fetch_all(
"SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 50"
))
row = fetch_one(
"SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg FROM social_mentions"
)
if row:
analytics["mention_count"] = int(row["cnt"])
analytics["avg_sentiment"] = float(row["avg"])
except Exception:
mentions = []
try:
accounts = _iso_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform"))
except Exception:
accounts = []
try:
rules = _iso_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))
logs = _iso_rows(fetch_all(
"""SELECT al.*, ar.name AS rule_name FROM agent_logs al
LEFT JOIN agent_rules ar ON al.rule_id = ar.id ORDER BY al.created_at DESC LIMIT 30"""
))
except Exception:
pass
return templates.TemplateResponse(
"marketing.html",
{
"request": request,
"page_title": "Marketing",
"scheduled_posts": posts,
"social_mentions": mentions,
"accounts": accounts,
"agent_rules": rules,
"agent_logs": logs,
"analytics": analytics,
},
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.db import fetch_all, fetch_one
from app.services.social_publish import PLATFORMS, get_configured_channels, run_publish_job
router = APIRouter(prefix="/api/marketing", tags=["marketing-api"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
UPLOAD_DIR = BASE_DIR / "static" / "uploads" / "marketing"
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for key, value in list(out.items()):
if hasattr(value, "isoformat"):
out[key] = value.isoformat()
return out
def _serialize_rows(rows: list[dict]) -> list[dict]:
return [_serialize_row(row) for row in rows]
class PublishRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
image_url: str | None = None
media_ids: list[int] = Field(default_factory=list)
channels: list[str] = Field(default_factory=list)
@router.post("/upload")
async def upload_marketing_media(file: UploadFile = File(...)) -> dict:
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="empty file")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename or "upload.bin").suffix or ".bin"
filename = f"{uuid4().hex}{ext.lower()}"
path = UPLOAD_DIR / filename
path.write_bytes(data)
media_url = f"/static/uploads/marketing/{filename}"
row = fetch_one(
"""
INSERT INTO marketing_media (filename, original_name, file_path, media_url, mime_type, size_bytes, created_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
filename,
file.filename or filename,
str(path),
media_url,
file.content_type or "application/octet-stream",
len(data),
),
)
return {"media_id": row["id"], "url": media_url}
@router.post("/publish", status_code=202)
def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks) -> dict:
channels = [c.strip().lower() for c in body.channels if c.strip()]
invalid = [c for c in channels if c not in PLATFORMS]
if invalid:
raise HTTPException(status_code=400, detail=f"Unsupported channels: {', '.join(invalid)}")
if not channels:
channels = list(PLATFORMS)
row = fetch_one(
"""
INSERT INTO social_publish_jobs (text, image_url, media_ids, channels, status, created_at, updated_at)
VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, NOW(), NOW())
RETURNING id
""",
(
body.text,
body.image_url,
json.dumps(body.media_ids),
json.dumps(channels),
"queued",
),
)
job_id = int(row["id"])
background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids)
return {"job_id": job_id, "status": "queued", "channels": channels}
@router.get("/publish/{job_id}")
def get_publish_job(job_id: int) -> dict:
row = fetch_one("SELECT * FROM social_publish_jobs WHERE id = %s", (job_id,))
if not row:
raise HTTPException(status_code=404, detail="Job not found")
return {"job": _serialize_row(row)}
@router.get("/publish/history")
def list_publish_history(limit: int = 50) -> dict:
safe_limit = max(1, min(limit, 200))
rows = fetch_all(
"SELECT * FROM social_publish_jobs ORDER BY created_at DESC LIMIT %s",
(safe_limit,),
)
return {"items": _serialize_rows(rows), "count": len(rows)}
@router.get("/channels")
def list_channels() -> dict:
items = get_configured_channels()
return {"items": items, "platforms": list(PLATFORMS)}
+9
View File
@@ -0,0 +1,9 @@
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
router = APIRouter(prefix="/monitor", tags=["monitor"])
@router.get("")
async def monitor_page():
return RedirectResponse(url="/browser#monitor", status_code=302)
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter(tags=["ops"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/ops")
async def ops_page(request: Request):
return templates.TemplateResponse(
"ops.html",
{
"request": request,
"page_title": "IT Ops",
},
)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import os
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
router = APIRouter(prefix="/api/ops", tags=["ops-api"])
async def _proxy(method: str, path: str, request: Request) -> JSONResponse:
body = await request.body()
upstream = f"{TOOLS_API_URL}/ops{path}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
method=method,
url=upstream,
params=dict(request.query_params),
content=body if body else None,
headers={"content-type": request.headers.get("content-type", "application/json")},
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"tools-api unavailable: {exc}") from exc
if resp.status_code >= 500:
raise HTTPException(status_code=502, detail=f"tools-api error {resp.status_code}")
try:
payload = resp.json()
except Exception: # noqa: BLE001
payload = {"raw": resp.text}
return JSONResponse(status_code=resp.status_code, content=payload)
@router.get("/status")
async def ops_status_proxy(request: Request):
return await _proxy("GET", "/status", request)
@router.get("/topology")
async def ops_topology_proxy(request: Request):
return await _proxy("GET", "/topology", request)
@router.post("/refresh")
async def ops_refresh_proxy(request: Request):
return await _proxy("POST", "/refresh", request)
@router.api_route("/{subpath:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def ops_generic_proxy(subpath: str, request: Request):
return await _proxy(request.method, f"/{subpath}", request)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.config import settings
router = APIRouter(tags=["packaging"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class PackagingGenerateBody(BaseModel):
type: str = Field(default="folding_box")
width_mm: float = Field(default=120, gt=0)
height_mm: float = Field(default=80, gt=0)
depth_mm: float = Field(default=40, ge=0)
elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = Field(default_factory=dict)
barcode_value: str | None = Field(default=None)
@router.get("/packaging")
async def packaging_page(request: Request):
return templates.TemplateResponse(
"packaging.html",
{"request": request, "page_title": "Packaging Studio"},
)
@router.post("/api/packaging/generate")
async def proxy_packaging_generate(body: PackagingGenerateBody):
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate",
json=body.model_dump(),
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/projects")
async def proxy_packaging_projects(limit: int = 30):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/projects",
params={"limit": limit},
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/download/{project_id}")
async def proxy_packaging_download(project_id: str, format: str = "svg"):
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{project_id}",
params={"format": format},
)
r.raise_for_status()
media = r.headers.get("content-type", "application/octet-stream")
disposition = r.headers.get("content-disposition")
headers = {"content-disposition": disposition} if disposition else {}
return Response(content=r.content, media_type=media, headers=headers)
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+113
View File
@@ -0,0 +1,113 @@
"""Unified live platform feed — events with traceable sources."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all
CHANNEL_ROUTES = {
"dashboard": "/",
"retail": "/retail",
"marketing": "/marketing",
"beurs": "/beurs",
"agents": "/agents",
"hermes": "/hermes",
"browser": "/browser",
"documents": "/documents",
"settings": "/settings",
}
def _resolve_source(row: dict[str, Any]) -> dict[str, Any]:
meta = row.get("metadata") or {}
if isinstance(meta, str):
import json
try:
meta = json.loads(meta)
except Exception:
meta = {}
source_url = meta.get("source_url") or meta.get("url") or meta.get("link")
source_label = meta.get("source") or meta.get("feed_name")
if not source_url:
channel = row.get("channel") or "dashboard"
source_url = CHANNEL_ROUTES.get(channel, "/")
source_label = source_label or f"Foodlinkk · {channel}"
if row.get("related_table") == "rss_items" and row.get("related_id"):
source_url = meta.get("link") or source_url
event_type = (row.get("event_type") or "").lower()
agent = (row.get("agent_name") or "").lower()
if event_type in ("briefing", "report"):
source_url = "/"
elif event_type in ("sync", "score", "import") and "retail" in agent:
source_url = "/retail"
elif event_type == "refresh" and "rss" in agent:
source_url = "/marketing"
elif event_type in ("sync",) and "halal" in agent:
source_url = "/retail"
elif agent == "herman":
source_url = "/"
elif agent in ("marketing", "rss_feeds"):
source_url = "/marketing"
elif agent in ("wholesale_scraper", "retail_intel"):
source_url = "/retail"
elif agent == "hermes":
source_url = "/hermes"
internal_url = source_url if source_url.startswith("/") else None
external_url = source_url if source_url and source_url.startswith("http") else None
return {
"source_url": source_url,
"source_label": source_label or "Foodlinkk platform",
"internal_url": internal_url,
"external_url": external_url,
}
def fetch_platform_events(limit: int = 80, agent: Optional[str] = None) -> list[dict[str, Any]]:
clauses, params = [], []
if agent:
clauses.append("LOWER(agent_name) = %s")
params.append(agent.lower())
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""SELECT id, agent_name, agent_type, event_type, title, body, status,
channel, metadata, related_table, related_id, created_at
FROM agent_events{where}
ORDER BY created_at DESC LIMIT %s""",
tuple(params + [limit]),
)
events = []
for r in rows:
item = dict(r)
if item.get("created_at"):
item["created_at"] = item["created_at"].isoformat()
src = _resolve_source(item)
item.update(src)
item["click_url"] = src.get("external_url") or src.get("internal_url") or "/agents"
item["is_external"] = bool(src.get("external_url"))
events.append(item)
return events
def platform_stats() -> dict[str, Any]:
try:
total = fetch_all("SELECT COUNT(*) AS n FROM agent_events")[0]["n"]
pending = fetch_all("SELECT COUNT(*) AS n FROM agent_events WHERE status = 'needs_approval'")[0]["n"]
last_hour = fetch_all(
"SELECT COUNT(*) AS n FROM agent_events WHERE created_at >= NOW() - INTERVAL '1 hour'"
)[0]["n"]
except Exception:
total = pending = last_hour = 0
return {
"total_events": int(total or 0),
"pending_approvals": int(pending or 0),
"events_last_hour": int(last_hour or 0),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/products", tags=["products"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def products_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT p.id, p.name, p.status, p.margin_pct, p.moq, p.shelf_target, p.launch_date, p.created_at,
c.name AS client_name
FROM products p LEFT JOIN clients c ON c.id = p.client_id
ORDER BY p.created_at DESC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("products.html", {"request": request, "page_title": "Products", "products": rows})
+39
View File
@@ -0,0 +1,39 @@
"""Proxy recommendations to Tools API."""
from __future__ import annotations
import os
import httpx
from fastapi import APIRouter
admin_router = None # patched into admin_api
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
async def _proxy(method: str, path: str):
async with httpx.AsyncClient(timeout=60) as client:
r = await getattr(client, method.lower())(f"{TOOLS}{path}")
return r.json()
def register_recommendation_routes(router: APIRouter) -> None:
@router.get("/recommendations/pending")
async def reco_pending():
return await _proxy("GET", "/recommendations/pending")
@router.post("/recommendations/generate")
async def reco_generate():
return await _proxy("POST", "/recommendations/generate")
@router.post("/recommendations/{rec_id}/approve")
async def reco_approve(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/approve")
@router.post("/recommendations/{rec_id}/dismiss")
async def reco_dismiss(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/dismiss")
@router.post("/research/run")
async def research_run():
return await _proxy("POST", "/research/run")
+78
View File
@@ -0,0 +1,78 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, PlainTextResponse, Response
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
from app.services.reports_export import EXPORT_DATASETS, export_all_json, fetch_dataset, list_datasets, to_csv
router = APIRouter(prefix="/reports", tags=["reports"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def reports_page(request: Request):
briefings, events = [], []
try:
briefings = _iso_rows(fetch_all(
"SELECT id, content, generated_by, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 20"
))
except Exception:
briefings = []
try:
events = _iso_rows(fetch_all(
"""SELECT id, agent_name, event_type, title, status, created_at FROM agent_events
WHERE event_type IN ('briefing','report','herman_chat') ORDER BY created_at DESC LIMIT 50"""
))
except Exception:
events = []
datasets = list_datasets()
return templates.TemplateResponse(
"reports.html",
{
"request": request,
"page_title": "Reports & Export",
"briefings": briefings,
"report_events": events,
"datasets": datasets,
},
)
@router.get("/api/datasets")
async def reports_datasets():
return JSONResponse({"items": list_datasets()})
@router.get("/api/export/{name}")
async def reports_export_one(name: str, format: str = Query("csv", pattern="^(csv|json)$"), limit: int = Query(10000, ge=1, le=50000)):
if name not in EXPORT_DATASETS:
raise HTTPException(404, "Dataset not found")
try:
rows = fetch_dataset(name, limit)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if format == "json":
return JSONResponse({"dataset": name, "count": len(rows), "items": rows})
csv_text = to_csv(rows)
return PlainTextResponse(
csv_text,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="foodlinkk_{name}.csv"'},
)
@router.get("/api/export-all")
async def reports_export_all(format: str = Query("json", pattern="^(json)$"), limit: int = Query(3000, ge=100, le=10000)):
bundle = export_all_json(limit)
return JSONResponse(bundle, headers={"Content-Disposition": 'attachment; filename="foodlinkk_full_export.json"'})
+333
View File
@@ -0,0 +1,333 @@
"""Retail intelligence map page + API proxy."""
from __future__ import annotations
import os
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from pydantic import BaseModel
router = APIRouter()
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
class CrmLinkBody(BaseModel):
client_id: int
deal_id: Optional[int] = None
relationship_type: str = "prospect"
partnership_status: Optional[str] = None
notes: Optional[str] = None
class NoteBody(BaseModel):
body: str
title: Optional[str] = None
note_type: str = "general"
class MilestoneBody(BaseModel):
title: str
milestone_type: str = "custom"
client_id: Optional[int] = None
deal_id: Optional[int] = None
target_date: Optional[str] = None
value_eur: Optional[float] = None
notes: Optional[str] = None
class OwnershipBody(BaseModel):
new_owner: str
previous_owner: Optional[str] = None
change_type: str = "acquisition"
effective_date: Optional[str] = None
source: Optional[str] = None
notes: Optional[str] = None
class CalendarBody(BaseModel):
title: str
starts_at: str
description: Optional[str] = None
ends_at: Optional[str] = None
client_id: Optional[int] = None
deal_id: Optional[int] = None
location: Optional[str] = None
class MediaBody(BaseModel):
filename: str
storage_path: str
content_type: str = "image/jpeg"
caption: Optional[str] = None
async def _tools_get(path: str, params: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(f"{TOOLS}{path}", params=params or {})
resp.raise_for_status()
return resp.json()
async def _tools_post(path: str, params: Optional[dict] = None, json_body: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(f"{TOOLS}{path}", params=params or {}, json=json_body)
resp.raise_for_status()
return resp.json()
async def _tools_delete(path: str) -> Any:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.delete(f"{TOOLS}{path}")
resp.raise_for_status()
return resp.json()
@router.get("/retail")
async def retail_page(request: Request):
stats = await _tools_get("/retail/stats")
filters = await _tools_get("/retail/filters")
schema = await _tools_get("/retail/schema")
trends = await _tools_get("/retail/trends", {"limit": 8})
crm = await _tools_get("/retail/crm/options")
return templates.TemplateResponse(
"retail.html",
{
"request": request,
"stats": stats,
"filters": filters,
"schema": schema,
"trends": trends.get("items", []),
"crm_options": crm,
},
)
@router.get("/api/retail/stats")
async def api_retail_stats(request: Request):
return JSONResponse(await _tools_get("/retail/stats", dict(request.query_params)))
@router.get("/api/retail/filters")
async def api_retail_filters():
return JSONResponse(await _tools_get("/retail/filters"))
@router.get("/api/retail/map")
async def api_retail_map(request: Request):
return JSONResponse(await _tools_get("/retail/map", dict(request.query_params)))
@router.get("/api/retail/list")
async def api_retail_list(request: Request):
return JSONResponse(await _tools_get("/retail/supermarkets", dict(request.query_params)))
@router.get("/api/retail/supermarkets/{store_id}")
async def api_retail_store(store_id: int):
return JSONResponse(await _tools_get(f"/retail/supermarkets/{store_id}"))
@router.get("/api/retail/opportunities")
async def api_retail_opportunities(request: Request):
return JSONResponse(await _tools_get("/retail/opportunities", dict(request.query_params)))
@router.get("/api/retail/trends")
async def api_retail_trends(request: Request):
return JSONResponse(await _tools_get("/retail/trends", dict(request.query_params)))
@router.get("/api/retail/crm/options")
async def api_crm_options():
return JSONResponse(await _tools_get("/retail/crm/options"))
@router.post("/api/retail/supermarkets/{store_id}/link")
async def api_link_store(store_id: int, body: CrmLinkBody):
return JSONResponse(await _tools_post(f"/retail/supermarkets/{store_id}/link", json_body=body.model_dump()))
@router.post("/api/retail/enrich")
async def api_retail_enrich(limit: int = Query(100, ge=1, le=200)):
return JSONResponse(await _tools_post("/retail/enrich", params={"limit": limit}))
@router.post("/api/retail/sync/{action}")
async def api_retail_sync(action: str, limit: int = Query(100, ge=1, le=300)):
paths = {
"halal": "/retail/sync/halal",
"contacts": f"/retail/sync/contacts?limit={limit}",
"trends": "/retail/sync/trends",
"opportunities": "/retail/compute-opportunities",
}
if action not in paths:
return JSONResponse({"error": "unknown action"}, status_code=400)
return JSONResponse(await _tools_post(paths[action]))
@router.get("/api/retail/export")
async def api_retail_export(request: Request):
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(f"{TOOLS}/retail/export", params=dict(request.query_params))
resp.raise_for_status()
return StreamingResponse(
iter([resp.text]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=retail_export.csv"},
)
# --- 360 workspace proxies ---
@router.get("/api/retail/360/{store_id}")
async def api_retail_360(store_id: int):
return JSONResponse(await _tools_get(f"/retail/360/{store_id}"))
@router.post("/api/retail/360/{store_id}/notes")
async def api_retail_note(store_id: int, body: NoteBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/notes", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/milestones")
async def api_retail_milestone(store_id: int, body: MilestoneBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/milestones", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/ownership")
async def api_retail_ownership(store_id: int, body: OwnershipBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/ownership", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/calendar")
async def api_retail_calendar(store_id: int, body: CalendarBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/calendar", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/media")
async def api_retail_media(store_id: int, body: MediaBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/media", json_body=body.model_dump()))
@router.get("/api/retail/cities")
async def api_retail_cities(request: Request):
return JSONResponse(await _tools_get("/retail/cities", dict(request.query_params)))
@router.post("/api/retail/cities/sync")
async def api_retail_cities_sync(limit: int = Query(50, ge=1, le=200)):
return JSONResponse(await _tools_post("/retail/cities/sync", params={"limit": limit}))
@router.get("/api/retail/wholesalers")
async def api_retail_wholesalers(request: Request):
return JSONResponse(await _tools_get("/retail/wholesalers", dict(request.query_params)))
@router.post("/api/retail/wholesalers/import")
async def api_retail_wholesalers_import():
return JSONResponse(await _tools_post("/retail/wholesalers/import"))
@router.get("/api/retail/rss/live")
async def api_retail_rss(request: Request):
return JSONResponse(await _tools_get("/retail/rss/live", dict(request.query_params)))
@router.post("/api/retail/rss/refresh")
async def api_retail_rss_refresh():
return JSONResponse(await _tools_post("/retail/rss/refresh"))
@router.get("/api/retail/rss/bookmarks")
async def api_rss_bookmarks(request: Request):
return JSONResponse(await _tools_get("/retail/rss/bookmarks", dict(request.query_params)))
@router.post("/api/retail/rss/bookmarks")
async def api_rss_bookmark_add(request: Request):
body = await request.json()
return JSONResponse(await _tools_post("/retail/rss/bookmarks", json_body=body))
@router.delete("/api/retail/rss/bookmarks/{rss_item_id}")
async def api_rss_bookmark_delete(rss_item_id: int):
return JSONResponse(await _tools_delete(f"/retail/rss/bookmarks/{rss_item_id}"))
@router.get("/api/retail/wholesalers/meta")
async def api_wholesalers_meta():
return JSONResponse(await _tools_get("/retail/wholesalers/meta"))
@router.get("/api/retail/wholesalers/{wh_id}/contacts")
async def api_wholesaler_contacts(wh_id: int):
return JSONResponse(await _tools_get(f"/retail/wholesalers/{wh_id}/contacts"))
@router.post("/api/retail/wholesalers/{wh_id}/contacts")
async def api_wholesaler_contact_add(wh_id: int, request: Request):
body = await request.json()
return JSONResponse(await _tools_post(f"/retail/wholesalers/{wh_id}/contacts", json_body=body))
@router.get("/api/retail/promo-campaigns")
async def api_promo_campaigns(request: Request):
return JSONResponse(await _tools_get("/retail/promo-campaigns", dict(request.query_params)))
@router.post("/api/retail/promo-campaigns")
async def api_promo_campaign_add(request: Request):
body = await request.json()
return JSONResponse(await _tools_post("/retail/promo-campaigns", json_body=body))
@router.post("/api/retail/reclamefolder/refresh")
async def api_reclamefolder_refresh():
return JSONResponse(await _tools_post("/retail/reclamefolder/refresh", json_body={}))
@router.get("/api/retail/reclamefolder/live")
async def api_reclamefolder_live(request: Request):
return JSONResponse(await _tools_get("/retail/reclamefolder/live", dict(request.query_params)))
@router.get("/api/retail/reclamefolder/chains")
async def api_reclamefolder_chains():
return JSONResponse(await _tools_get("/retail/reclamefolder/chains"))
@router.get("/api/retail/market/supermarkets")
async def api_supermarket_market():
return JSONResponse(await _tools_get("/retail/market/supermarkets"))
@router.get("/api/retail/market/food-trends")
async def api_food_trends():
return JSONResponse(await _tools_get("/retail/market/food-trends"))
@router.get("/api/retail/market/concepts")
async def api_market_concepts(request: Request):
return JSONResponse(await _tools_get("/retail/market/concepts", dict(request.query_params)))
@router.get("/api/retail/live-dashboard")
async def api_retail_live_dashboard():
return JSONResponse(await _tools_get("/retail/live-dashboard"))
@router.get("/api/retail/market/stocks")
async def api_retail_market_stocks():
return JSONResponse(await _tools_get("/retail/market/stocks"))
@router.get("/api/retail/regulations")
async def api_retail_regulations(request: Request):
return JSONResponse(await _tools_get("/retail/regulations", dict(request.query_params)))
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": tab if tab in allowed else "email",
},
)
+383
View File
@@ -0,0 +1,383 @@
"""Settings API — email accounts stored in PostgreSQL."""
from __future__ import annotations
import smtplib
import json
from datetime import datetime
from email.mime.text import MIMEText
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls
from app.services.social_publish import PLATFORMS, get_integration, test_connection
settings_router = APIRouter(prefix="/api/settings", tags=["settings"])
class EmailAccountBody(BaseModel):
label: str = Field(..., max_length=128)
email_address: str = Field(..., max_length=255)
provider: str = Field(default="custom", max_length=32)
is_active: bool = False
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
imap_host: Optional[str] = None
imap_port: int = 993
imap_user: Optional[str] = None
imap_password: Optional[str] = None
sync_enabled: bool = False
def _mask_account(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
out["smtp_password_set"] = bool(row.get("smtp_password"))
out["imap_password_set"] = bool(row.get("imap_password"))
out.pop("smtp_password", None)
out.pop("imap_password", None)
return out
def _deactivate_all() -> None:
execute("UPDATE email_accounts SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
def _test_smtp_config(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_pass: str,
from_addr: str,
) -> tuple[bool, str]:
if not smtp_host or not from_addr:
return False, "SMTP host en from-adres zijn verplicht"
try:
msg = MIMEText("Foodlinkk SMTP test — Herman email settings OK.", "plain", "utf-8")
msg["Subject"] = "Foodlinkk test email"
msg["From"] = from_addr
msg["To"] = from_addr
with smtplib.SMTP(smtp_host, smtp_port, timeout=25) as server:
server.ehlo()
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, [from_addr], msg.as_string())
return True, f"Testmail verstuurd naar {from_addr}"
except Exception as exc:
return False, str(exc)
def _resolve_password(new: Optional[str], existing: Optional[str]) -> Optional[str]:
if new is not None and new != "":
return new
return existing
SOCIAL_PLATFORM_FIELDS: dict[str, tuple[str, ...]] = {
"twitter": ("api_key", "api_secret", "access_token", "access_secret"),
"linkedin": ("access_token", "person_urn"),
"instagram": ("access_token", "page_id"),
"facebook": ("access_token", "page_id"),
"tiktok": ("access_token", "open_id"),
"pinterest": ("access_token", "board_id"),
}
SOCIAL_SECRET_FIELDS = {"api_secret", "access_secret", "access_token", "api_key"}
class SocialIntegrationBody(BaseModel):
api_key: Optional[str] = None
api_secret: Optional[str] = None
access_token: Optional[str] = None
access_secret: Optional[str] = None
person_urn: Optional[str] = None
page_id: Optional[str] = None
open_id: Optional[str] = None
board_id: Optional[str] = None
is_active: Optional[bool] = None
def _mask_social_row(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
config = out.get("config") or {}
if isinstance(config, str):
try:
config = json.loads(config)
except Exception:
config = {}
if not isinstance(config, dict):
config = {}
masked = dict(config)
for key in SOCIAL_SECRET_FIELDS:
if key in config:
masked[f"{key}_set"] = bool(config.get(key))
masked.pop(key, None)
out["config"] = masked
return out
def _normalize_social_platform(platform: str) -> str:
value = (platform or "").strip().lower()
if value not in PLATFORMS:
raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}")
return value
@settings_router.get("/email")
def list_email_accounts() -> dict[str, Any]:
try:
rows = fetch_all("SELECT * FROM email_accounts ORDER BY is_active DESC, updated_at DESC")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"accounts": [_mask_account(r) for r in rows]}
@settings_router.post("/email")
def create_email_account(body: EmailAccountBody) -> dict[str, Any]:
if body.is_active:
_deactivate_all()
try:
row = fetch_one(
"""
INSERT INTO email_accounts (
label, email_address, provider, is_active,
smtp_host, smtp_port, smtp_user, smtp_password,
imap_host, imap_port, imap_user, imap_password, sync_enabled
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password or "",
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
body.imap_password or "",
body.sync_enabled,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.put("/email/{account_id}")
def update_email_account(account_id: int, body: EmailAccountBody) -> dict[str, Any]:
existing = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
if body.is_active:
_deactivate_all()
smtp_pass = _resolve_password(body.smtp_password, existing.get("smtp_password"))
imap_pass = _resolve_password(body.imap_password, existing.get("imap_password"))
try:
row = fetch_one(
"""
UPDATE email_accounts SET
label=%s, email_address=%s, provider=%s, is_active=%s,
smtp_host=%s, smtp_port=%s, smtp_user=%s, smtp_password=%s,
imap_host=%s, imap_port=%s, imap_user=%s, imap_password=%s,
sync_enabled=%s, updated_at=NOW()
WHERE id=%s RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
smtp_pass,
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
imap_pass,
body.sync_enabled,
account_id,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.delete("/email/{account_id}")
def delete_email_account(account_id: int) -> dict[str, Any]:
execute("DELETE FROM email_accounts WHERE id = %s", (account_id,))
return {"ok": True}
@settings_router.post("/email/{account_id}/activate")
def activate_email_account(account_id: int) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
_deactivate_all()
row = fetch_one(
"UPDATE email_accounts SET is_active=TRUE, updated_at=NOW() WHERE id=%s RETURNING *",
(account_id,),
)
return {"ok": True, "account": _mask_account(row)}
@settings_router.post("/email/{account_id}/test")
def test_saved_email_account(account_id: int) -> dict[str, Any]:
row = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not row:
raise HTTPException(status_code=404, detail="Account not found")
ok, message = _test_smtp_config(
row.get("smtp_host") or "",
int(row.get("smtp_port") or 587),
row.get("smtp_user") or row.get("email_address") or "",
row.get("smtp_password") or "",
row.get("email_address") or row.get("smtp_user") or "",
)
status = "ok" if ok else "failed"
execute(
"""
UPDATE email_accounts SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE id=%s
""",
(status, message[:500], account_id),
)
return {"ok": ok, "message": message}
@settings_router.post("/email/test")
def test_email_config(body: EmailAccountBody) -> dict[str, Any]:
"""Test SMTP without saving (form preview)."""
if not body.smtp_password:
raise HTTPException(status_code=400, detail="SMTP wachtwoord is verplicht voor test zonder opgeslagen account")
ok, message = _test_smtp_config(
body.smtp_host or "",
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password,
body.email_address,
)
return {"ok": ok, "message": message}
@settings_router.get("/social")
def list_social_integrations() -> dict[str, Any]:
rows = fetch_all("SELECT * FROM social_integrations ORDER BY platform")
return {"items": [_mask_social_row(r) for r in rows], "platforms": list(PLATFORMS)}
@settings_router.put("/social/{platform}")
def save_social_integration(platform: str, body: SocialIntegrationBody) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
allowed_fields = set(SOCIAL_PLATFORM_FIELDS[platform])
incoming = body.model_dump(exclude_none=True)
existing = fetch_one("SELECT * FROM social_integrations WHERE platform = %s", (platform,))
existing_config = {}
if existing:
existing_config = existing.get("config") or {}
if isinstance(existing_config, str):
try:
existing_config = json.loads(existing_config)
except Exception:
existing_config = {}
if not isinstance(existing_config, dict):
existing_config = {}
config = dict(existing_config)
for field in allowed_fields:
if field not in incoming:
continue
value = incoming.get(field)
if field in SOCIAL_SECRET_FIELDS:
if value is not None and value != "":
config[field] = value
else:
config[field] = value
is_active = body.is_active
if is_active is None:
is_active = bool(existing.get("is_active")) if existing else True
row = fetch_one(
"""
INSERT INTO social_integrations (platform, config, is_active, updated_at)
VALUES (%s, %s::jsonb, %s, NOW())
ON CONFLICT (platform) DO UPDATE SET
config = EXCLUDED.config,
is_active = EXCLUDED.is_active,
updated_at = NOW()
RETURNING *
""",
(platform, json.dumps(config), is_active),
)
return {"ok": True, "integration": _mask_social_row(row)}
@settings_router.post("/social/{platform}/test")
def test_social_integration(platform: str) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
integration = get_integration(platform)
if not integration:
raise HTTPException(status_code=404, detail="Integration not configured")
result = test_connection(platform, integration)
status = "ok" if result.get("ok") else "failed"
message = (result.get("message") or result.get("error") or "")[:500]
try:
execute(
"""
UPDATE social_integrations
SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE platform=%s
""",
(status, message, platform),
)
except Exception:
pass
return {"platform": platform, **result}
class PermissionBody(BaseModel):
granted: bool
@settings_router.get("/permissions")
def list_permissions() -> dict[str, Any]:
items = agent_souls.list_permissions()
granted = sum(1 for i in items if i.get("granted"))
return {"items": items, "granted_count": granted, "total": len(items)}
@settings_router.put("/permissions/{module_key}")
def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
row = agent_souls.update_permission(module_key, body.granted)
if not row:
raise HTTPException(404, "Module not found")
return {"permission": row}
@settings_router.post("/permissions/grant-all")
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter(prefix="/studio", tags=["studio"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("")
async def studio_page(request: Request):
return templates.TemplateResponse(
"studio.html",
{"request": request, "page_title": "AI Studio"},
)
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def suppliers_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT id, name, country, category, moq, lead_time_days, rating, contact, created_at
FROM suppliers ORDER BY rating DESC NULLS LAST, name ASC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("suppliers.html", {"request": request, "page_title": "Suppliers", "suppliers": rows})
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/voice", tags=["voice"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def voice_page(request: Request):
events = []
try:
events = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, status, created_at FROM agent_events
WHERE channel = 'voice' OR event_type LIKE 'voice%%' ORDER BY created_at DESC LIMIT 25"""
))
except Exception:
events = []
return templates.TemplateResponse("voice.html", {"request": request, "page_title": "Voice", "voice_events": events})
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
"""Agent soul profiles and Herman permissions."""
from __future__ import annotations
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
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
FROM agent_souls s ORDER BY s.display_name"""
)
return [dict(r) for r in rows]
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(),),
)
if not row:
return None
events = fetch_all(
"""SELECT id, event_type, title, status, created_at FROM agent_events
WHERE LOWER(agent_name) = %s ORDER BY created_at DESC LIMIT 15""",
(agent_key.lower(),),
)
out = dict(row)
out["recent_events"] = [dict(e) for e in events]
return out
def update_soul(agent_key: str, **fields: Any) -> dict[str, Any]:
allowed = ("display_name", "role_title", "soul_md", "responsibilities", "permissions", "is_active")
sets, params = [], []
for k, v in fields.items():
if k in allowed and v is not None:
sets.append(f"{k} = %s")
params.append(v)
if not sets:
soul = get_soul(agent_key)
if not soul:
raise ValueError("Agent not found")
return soul
params.append(agent_key.lower())
execute(f"UPDATE agent_souls SET {', '.join(sets)}, updated_at = NOW() WHERE agent_key = %s", tuple(params))
return get_soul(agent_key) or {}
def list_permissions() -> list[dict[str, Any]]:
return [dict(r) for r in fetch_all("SELECT * FROM herman_permissions ORDER BY category, module_label")]
def update_permission(module_key: str, granted: bool) -> dict[str, Any]:
execute(
"""UPDATE herman_permissions SET granted = %s, granted_at = CASE WHEN %s THEN NOW() ELSE NULL END, updated_at = NOW()
WHERE module_key = %s""",
(granted, granted, module_key),
)
row = fetch_one("SELECT * FROM herman_permissions WHERE module_key = %s", (module_key,))
return dict(row or {})
def grant_all_permissions() -> int:
execute("UPDATE herman_permissions SET granted = TRUE, granted_at = NOW(), updated_at = NOW()")
row = fetch_one("SELECT COUNT(*) AS n FROM herman_permissions WHERE granted = TRUE")
return int((row or {}).get("n") or 0)
+233
View File
@@ -0,0 +1,233 @@
"""Comprehensive analytics data from all DB tables with optional filters."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all, fetch_one
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
return int(row["c"]) if row else 0
except Exception:
return 0
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
elif val is not None and type(val).__name__ == "Decimal":
row[key] = float(val)
return rows
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {}
chain = f.get("chain") or None
province = f.get("province") or None
stage = f.get("stage") or None
agent = f.get("agent") or None
days = int(f.get("days") or 90)
data: dict[str, Any] = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"filters": f,
}
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)
),
"supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"),
"crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
"rss_items": _safe_count("rss_items"),
"rss_bookmarks": _safe_count("rss_bookmarks"),
"agent_events": _safe_count("agent_events"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"promo_campaigns": _safe_count("promo_campaigns", "status = 'active'"),
"contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"),
}
try:
data["clients_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt FROM clients GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["clients_by_stage"] = []
try:
data["deals_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value),0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["deals_by_stage"] = []
try:
data["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
)
except Exception:
data["events_by_agent"] = []
store_where, store_params = [], []
if chain:
store_where.append("chain = %s")
store_params.append(chain)
if province:
store_where.append("province = %s")
store_params.append(province)
sw = (" WHERE " + " AND ".join(store_where)) if store_where else ""
try:
data["supermarkets_by_chain"] = fetch_all(
f"SELECT chain, COUNT(*) AS cnt FROM supermarkets{sw} GROUP BY chain ORDER BY cnt DESC LIMIT 15",
tuple(store_params) if store_params else None,
)
except Exception:
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"
)
except Exception:
data["supermarkets_by_province"] = []
try:
data["partnership_breakdown"] = fetch_all(
"SELECT COALESCE(partnership_status,'none') AS status, COUNT(*) AS cnt FROM supermarkets GROUP BY partnership_status ORDER BY cnt DESC"
)
except Exception:
data["partnership_breakdown"] = []
try:
data["wholesalers_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM wholesalers WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["wholesalers_by_province"] = []
try:
data["rss_by_category"] = fetch_all(
"""SELECT f.category, COUNT(i.id) AS cnt FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id GROUP BY f.category ORDER BY cnt DESC"""
)
except Exception:
data["rss_by_category"] = []
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
)
except Exception:
data["events_timeline"] = []
try:
data["milestones_by_status"] = fetch_all(
"SELECT status, COUNT(*) AS cnt FROM sales_milestones GROUP BY status ORDER BY cnt DESC"
)
except Exception:
data["milestones_by_status"] = []
try:
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"""
)
except Exception:
data["top_opportunities"] = []
try:
data["sentiment_distribution"] = fetch_all(
"SELECT sentiment_label, COUNT(*) AS cnt FROM document_analytics GROUP BY sentiment_label"
)
except Exception:
data["sentiment_distribution"] = []
try:
data["top_words"] = fetch_all(
"""SELECT lemma, SUM(count) AS total FROM document_word_counts
WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 15"""
)
except Exception:
data["top_words"] = []
try:
data["promo_by_chain"] = fetch_all(
"SELECT chain, COUNT(*) AS cnt FROM promo_campaigns WHERE status = 'active' GROUP BY chain ORDER BY cnt DESC"
)
except Exception:
data["promo_by_chain"] = []
deal_where = ""
deal_params: tuple = ()
if stage:
deal_where = " WHERE stage = %s"
deal_params = (stage,)
try:
data["recent_deals"] = fetch_all(
f"SELECT title, stage, value, updated_at FROM deals{deal_where} ORDER BY updated_at DESC LIMIT 10",
deal_params or None,
)
except Exception:
data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
if agent:
agent_where += " AND agent_name = %s"
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""",
(agent,),
)
except Exception:
data["recent_events"] = []
else:
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25"""
)
except Exception:
data["recent_events"] = []
try:
data["filter_meta"] = {
"chains": fetch_all("SELECT DISTINCT chain FROM supermarkets WHERE chain IS NOT NULL ORDER BY chain"),
"provinces": fetch_all("SELECT DISTINCT province FROM supermarkets WHERE province IS NOT NULL ORDER BY province"),
"client_stages": fetch_all("SELECT DISTINCT stage FROM clients ORDER BY stage"),
"deal_stages": fetch_all("SELECT DISTINCT stage FROM deals ORDER BY stage"),
"agents": fetch_all("SELECT DISTINCT agent_name FROM agent_events ORDER BY agent_name"),
}
except Exception:
data["filter_meta"] = {}
for key in (
"clients_by_stage", "deals_by_stage", "events_by_agent", "supermarkets_by_chain",
"supermarkets_by_province", "partnership_breakdown", "wholesalers_by_province",
"rss_by_category", "events_timeline", "milestones_by_status", "top_opportunities",
"sentiment_distribution", "top_words", "promo_by_chain", "recent_deals", "recent_events",
):
if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key])
return data
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import market_stocks, ollama
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "", params: tuple = ()) -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}", params or None)
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def serialize_stats(data: dict[str, Any]) -> dict[str, Any]:
def _default(o: Any) -> Any:
if hasattr(o, "isoformat"):
return o.isoformat()
if hasattr(o, "__float__"):
try:
return float(o)
except (TypeError, ValueError):
pass
return str(o)
return json.loads(json.dumps(data, default=_default))
def collect_briefing_data() -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
data["clients"] = _safe_count("clients")
data["deals"] = _safe_count("deals")
data["products"] = _safe_count("products")
data["suppliers"] = _safe_count("suppliers")
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
try:
data["deals_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["deals_by_stage"] = []
try:
data["recent_clients"] = fetch_all(
"SELECT name, stage, email, created_at FROM clients ORDER BY created_at DESC LIMIT 5"
)
except Exception:
data["recent_clients"] = []
try:
data["recent_events"] = fetch_all(
"""SELECT agent_name, event_type, title, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 12"""
)
except Exception:
data["recent_events"] = []
try:
data["pending_items"] = fetch_all(
"""SELECT agent_name, title, event_type, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at DESC LIMIT 8"""
)
except Exception:
data["pending_items"] = []
try:
row = fetch_one(
"""SELECT COUNT(*) AS docs, COALESCE(SUM(word_count), 0) AS words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
FROM document_analytics"""
)
data["nas_docs"] = int(row["docs"] or 0) if row else 0
data["nas_words"] = int(row["words"] or 0) if row else 0
data["nas_sentiment"] = round(float(row["avg_sentiment"] or 0), 3) if row else 0.0
except Exception:
data["nas_docs"] = data["nas_words"] = 0
data["nas_sentiment"] = 0.0
try:
data["nas_files"] = fetch_all(
"""SELECT filename, doc_type, sentiment_label, word_count
FROM document_analytics ORDER BY analyzed_at DESC LIMIT 8"""
)
except Exception:
data["nas_files"] = []
try:
data["top_words"] = fetch_all(
"""SELECT lemma, SUM(count) AS total FROM document_word_counts
WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 10"""
)
except Exception:
data["top_words"] = []
try:
data["calendar_events"] = fetch_all(
"""SELECT ce.title, ce.starts_at, ce.ends_at, c.name AS client_name
FROM calendar_events ce
LEFT JOIN clients c ON c.id = ce.client_id
WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
AND ce.starts_at <= NOW() + INTERVAL '7 days'
ORDER BY ce.starts_at ASC LIMIT 10"""
)
except Exception:
data["calendar_events"] = []
# Retail intelligence
data["supermarkets"] = _safe_count("supermarkets")
data["clients_active"] = _safe_count("clients", "stage = 'active'")
data["clients_total"] = _safe_count("clients")
data["crm_partnerships"] = _safe_count("supermarkets", "partnership_status = 'active'")
data["wholesalers"] = _safe_count("wholesalers")
data["rss_bookmarks"] = _safe_count("rss_bookmarks")
data["promo_campaigns"] = _safe_count("promo_campaigns", "status = 'active'")
try:
data["top_opportunities"] = fetch_all(
"""SELECT s.name, s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros
JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 5"""
)
except Exception:
data["top_opportunities"] = []
try:
data["milestones_pending"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.target_date, sm.value_eur,
s.name AS store_name, s.chain, c.name AS client_name
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
LEFT JOIN clients c ON c.id = sm.client_id
WHERE sm.status IN ('pending', 'in_progress')
ORDER BY sm.target_date ASC NULLS LAST, sm.created_at DESC LIMIT 8"""
)
except Exception:
data["milestones_pending"] = []
try:
data["milestones_recent"] = fetch_all(
"""SELECT sm.title, sm.milestone_type, sm.status, sm.completed_at, sm.value_eur,
s.name AS store_name, s.chain
FROM sales_milestones sm
LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
ORDER BY sm.created_at DESC LIMIT 5"""
)
except Exception:
data["milestones_recent"] = []
try:
data["rss_highlights"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE i.title ILIKE ANY (ARRAY['%kant%','%maaltijd%','%supermarkt%','%retail%','%halal%','%jumbo%','%meal%'])
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["rss_highlights"] = []
try:
data["market_trends"] = fetch_all(
"SELECT trend_name, description, opportunity_score FROM market_trends ORDER BY updated_at DESC LIMIT 4"
)
except Exception:
data["market_trends"] = []
try:
quotes = market_stocks.fetch_retail_quotes()
data["market_stocks"] = quotes
data["market_summary"] = market_stocks.market_summary(quotes)
except Exception:
data["market_stocks"] = []
data["market_summary"] = {}
try:
data["regulation_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('regelgeving', 'cbs')
ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
)
except Exception:
data["regulation_highlights"] = []
try:
data["food_market_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR i.title ILIKE ANY (ARRAY['%supermarkt%','%retail%','%jumbo%','%ahold%','%halal%','%maaltijd%'])
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
return data
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
"",
"## KPI's",
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
f"- **Supermarkten in DB:** {data.get('supermarkets', 0)} · **CRM partnerships:** {data.get('crm_partnerships', 0)}",
f"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}",
"",
]
if data.get("top_opportunities"):
lines.extend(["## Top halal-markt kansen (Retail 360)"])
for row in data["top_opportunities"]:
score = round(float(row.get("halal_opportunity_score") or 0))
lines.append(f"- **{row.get('chain')} · {row.get('name')}** ({row.get('city')}) — score {score}/100")
lines.append("")
if data.get("milestones_pending"):
lines.extend(["## Sales milestones — open"])
for row in data["milestones_pending"]:
td = row.get("target_date")
td_s = td.isoformat()[:10] if hasattr(td, "isoformat") else str(td or "")[:10]
lines.append(f"- [{td_s}] **{row.get('title')}** · {row.get('chain') or ''} {row.get('store_name') or ''} · €{row.get('value_eur') or ''}")
lines.append("")
if data.get("rss_highlights"):
lines.extend(["## Kant-en-klaar & supermarkt nieuws"])
for row in data["rss_highlights"]:
lines.append(f"- [{row.get('feed_name')}] {row.get('title')}")
lines.append("")
if data.get("market_trends"):
lines.extend(["## Markt trends"])
for row in data["market_trends"]:
pct = round(float(row.get("opportunity_score") or 0) * 100)
lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}")
lines.append("")
lines.extend(["## Pipeline per stage"])
for row in data.get("deals_by_stage") or []:
lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
if not data.get("deals_by_stage"):
lines.append("- Geen deals in database.")
if data.get("calendar_events"):
lines.extend(["", "## Agenda (7 dagen)"])
for row in data["calendar_events"]:
ts = row.get("starts_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("pending_items"):
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
return "\n".join(lines)
async def _ai_executive_summary(data: dict[str, Any]) -> str:
opp_lines = ""
for row in data.get("top_opportunities") or []:
opp_lines += f"- {row.get('chain')} {row.get('name')} ({row.get('city')}): score {round(float(row.get('halal_opportunity_score') or 0))}\n"
ms_lines = ""
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: wat is vandaag belangrijk, pipeline, retail kansen, milestones)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 concrete bullets met CRM/retail acties)\n\n"
"## Lange termijn focus\n(3-5 bullets: groei supermarkt partnerships, halal markt, milestones komende weken)\n\n"
f"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf warm, professioneel en actionable. "
"Focus op halal kant-en-klaar retail groei in Nederland. Geen vage tekst — concrete namen en acties."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
def _fallback_summary(data: dict[str, Any]) -> str:
opp = data.get("top_opportunities") or []
ms = data.get("milestones_pending") or []
lines = [
"## Samenvatting",
f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
]
if opp:
top = opp[0]
lines.append(
f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
)
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
f"Behandel {data['pending_approvals']} openstaande agent-goedkeuringen",
"Check Marketing Live Feed voor kant-en-klaar trends",
]
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
lines.extend([
"- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
"- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
"- Wekelijks milestones review in Retail 360 sales tab",
])
return "\n".join(lines)
def _save_briefing(content: str, data: dict[str, Any]) -> None:
safe = serialize_stats(data)
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
try:
execute(
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
(content, "herman", json.dumps(metadata)),
)
except Exception:
pass
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
"completed", "dashboard", json.dumps({"stats": safe}),
),
)
except Exception:
pass
async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
return content, serialize_stats(data)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
"bizdev": {"name": "BizDev", "persona": "Pipeline, retail partnerships, deal structuring."},
"finance": {"name": "Finance", "persona": "Margins, cashflow, pricing for food brands."},
"sourcing": {"name": "Sourcing", "persona": "Suppliers, MOQ, lead times, procurement."},
"product": {"name": "Product", "persona": "SKU development, launch timelines, shelf readiness."},
"halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."},
"design": {"name": "Design", "persona": "Packaging, visual identity, retail presentation."},
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
}
IMAGE_KEYWORDS = (
"maak foto", "maak een foto", "genereer foto", "genereer afbeelding",
"maak afbeelding", "productfoto", "genereer image", "generate image",
"make image", "maak plaatje", "/genfoto",
)
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in IMAGE_KEYWORDS)
def _extract_image_prompt(raw: str) -> str:
t = raw.strip()
lower = t.lower()
for k in IMAGE_KEYWORDS:
if lower.startswith(k):
rest = t[len(k):].strip(" :,-")
if rest:
return rest
for k in IMAGE_KEYWORDS:
if k in lower:
idx = lower.index(k) + len(k)
rest = t[idx:].strip(" :,-")
if rest:
return rest
return t
async def _log_event(agent_name: str, event_type: str, title: str, body: str, metadata: dict | None = None) -> None:
payload = {
"agent_name": agent_name,
"agent_type": "herman_delegate",
"event_type": event_type,
"title": title[:255],
"body": body,
"metadata": metadata or {},
"status": "completed",
"channel": "herman",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
await client.post(f"{settings.TOOLS_API_URL.rstrip('/')}/events", json=payload)
except Exception:
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(agent_name, "herman_delegate", event_type, title[:255], body, "completed", "herman", json.dumps(metadata or {})),
)
except Exception:
pass
def _pick_agent(raw: str) -> str:
text = (raw or "").strip().lower()
first = text.split()[0].replace(",", "").replace(".", "") if text else "knowledge"
if first in AGENTS:
return first
for k in AGENTS:
if k in text:
return k
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
if _wants_image(message):
prompt = _extract_image_prompt(message)
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
json={"prompt": prompt, "width": 512, "height": 512, "steps": 15},
)
r.raise_for_status()
data = r.json()
filename = data.get("filename", "")
subfolder = data.get("subfolder", "")
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
}
except Exception as exc:
return {
"agent": "design",
"agent_label": "Design",
"reply": f"Kon geen afbeelding genereren: {exc}",
}
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
await _log_event(
"herman",
"openswarm_delegation",
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "reason": data.get("routing_reason", "")},
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": data.get("routing_reason", ""),
}
except Exception as exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"Herman orchestrator niet bereikbaar: {exc}",
}
async def generate_briefing() -> str:
from app.services.briefing import generate_daily_briefing
content, stats = await generate_daily_briefing()
await _log_event("herman", "briefing", "CEO briefing generated", content[:1500], {"stats": stats})
return content
+89
View File
@@ -0,0 +1,89 @@
"""Fetch retail stock quotes — delegates to tools-api when available."""
from __future__ import annotations
import json
import os
from typing import Any
from urllib.request import Request, urlopen
USER_AGENT = "Foodlinkk-MarketIntel/1.0"
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
RETAIL_STOCKS = [
{"symbol": "AD.AS", "name": "Ahold Delhaize", "chain": "Albert Heijn / Gall", "market": "Euronext"},
{"symbol": "TSCO.L", "name": "Tesco", "chain": "Tesco UK", "market": "LSE"},
{"symbol": "CAR.PA", "name": "Carrefour", "chain": "Carrefour EU", "market": "Euronext Paris"},
{"symbol": "SBRY.L", "name": "Sainsbury's", "chain": "Sainsbury's", "market": "LSE"},
{"symbol": "MKS.L", "name": "Marks & Spencer", "chain": "M&S Food", "market": "LSE"},
{"symbol": "WMT", "name": "Walmart", "chain": "Global benchmark", "market": "NYSE"},
{"symbol": "ULVR.L", "name": "Unilever", "chain": "FMCG / food", "market": "LSE"},
]
def _fetch_chart(symbol: str) -> dict[str, Any]:
url = (
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
f"?interval=1d&range=1mo&includePrePost=false"
)
req = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=12) as resp:
payload = json.loads(resp.read().decode())
result = (payload.get("chart") or {}).get("result") or []
if not result:
return {}
meta = result[0].get("meta") or {}
closes = (result[0].get("indicators") or {}).get("quote") or [{}]
close_series = closes[0].get("close") or []
valid = [c for c in close_series if c is not None]
sparkline = valid[-14:] if len(valid) >= 14 else valid
prev = valid[-2] if len(valid) >= 2 else None
last = valid[-1] if valid else meta.get("regularMarketPrice")
change_pct = meta.get("regularMarketChangePercent")
if change_pct is None and prev and last and prev:
change_pct = ((last - prev) / prev) * 100
return {
"price": meta.get("regularMarketPrice") or last,
"currency": meta.get("currency") or "EUR",
"change_pct": round(float(change_pct or 0), 2),
"sparkline": [round(float(v), 2) for v in sparkline],
"market_state": meta.get("marketState") or "CLOSED",
}
def fetch_retail_quotes() -> list[dict[str, Any]]:
try:
req = Request(f"{TOOLS}/retail/market/stocks", headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if data.get("items"):
return data["items"]
except Exception:
pass
items: list[dict[str, Any]] = []
for stock in RETAIL_STOCKS:
row = dict(stock)
try:
chart = _fetch_chart(stock["symbol"])
row.update(chart)
row["trend"] = "up" if (row.get("change_pct") or 0) >= 0 else "down"
except Exception:
row["price"] = None
row["change_pct"] = 0
row["sparkline"] = []
row["trend"] = "flat"
items.append(row)
return items
def market_summary(quotes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
quotes = quotes or fetch_retail_quotes()
valid = [q for q in quotes if q.get("price") is not None]
avg_change = sum(float(q.get("change_pct") or 0) for q in valid) / len(valid) if valid else 0
best = max(valid, key=lambda q: float(q.get("change_pct") or 0), default=None)
worst = min(valid, key=lambda q: float(q.get("change_pct") or 0), default=None)
return {
"avg_change_pct": round(avg_change, 2),
"best_performer": best,
"worst_performer": worst,
"quote_count": len(valid),
}
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from psycopg2.extras import RealDictCursor
from app.db import get_connection
def sentiment_score(text: str) -> float:
try:
from textblob import TextBlob
blob = TextBlob(text)
score = (blob.sentiment.polarity + 1) * 2 + 1
except Exception:
t = text.lower()
neg = sum(1 for w in ("bad", "teleurgest", "klacht", "lang", "duur", "fout") if w in t)
pos = sum(1 for w in ("geweldig", "aanrader", "fantast", "mooi", "lekker", "top") if w in t)
raw = 3.0 + (pos - neg) * 0.5
score = max(1.0, min(5.0, raw))
return max(1.0, min(5.0, round(float(score), 2)))
def evaluate_agent_rules(mention_id: Optional[int] = None) -> None:
with get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM agent_rules WHERE is_active = TRUE")
rules = cur.fetchall()
for rule in rules:
if rule["condition_type"] == "sentiment_below":
threshold = rule["threshold"] or 2.0
if mention_id:
cur.execute(
"SELECT id, text, sentiment_score FROM social_mentions WHERE id = %s AND sentiment_score < %s",
(mention_id, threshold),
)
else:
cur.execute(
"SELECT id, text, sentiment_score FROM social_mentions WHERE sentiment_score < %s ORDER BY created_at DESC LIMIT 5",
(threshold,),
)
matches = cur.fetchall()
for m in matches:
cur.execute(
"SELECT 1 FROM agent_logs WHERE rule_id = %s AND message LIKE %s",
(rule["id"], f"%mention #{m['id']}%"),
)
if cur.fetchone():
continue
msg = (
f"ALERT [{rule['name']}]: Negatief sentiment ({m['sentiment_score']}/5) "
f"op mention #{m['id']}: {(m['text'] or '')[:120]}"
)
cur.execute(
"INSERT INTO agent_logs (rule_id, message) VALUES (%s, %s)",
(rule["id"], msg),
)
elif rule["condition_type"] == "mention_spike":
threshold = int(rule["threshold"] or 5)
since = datetime.now() - timedelta(hours=24)
cur.execute(
"SELECT COUNT(*) AS cnt FROM social_mentions WHERE created_at > %s",
(since,),
)
count = cur.fetchone()["cnt"]
if count >= threshold:
msg = f"ALERT [{rule['name']}]: {count} mentions in 24u (drempel: {threshold})"
cur.execute(
"SELECT 1 FROM agent_logs WHERE rule_id = %s AND message = %s AND created_at > %s",
(rule["id"], msg, since),
)
if not cur.fetchone():
cur.execute(
"INSERT INTO agent_logs (rule_id, message) VALUES (%s, %s)",
(rule["id"], msg),
)
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import hashlib
import re
import subprocess
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup
from app.db import execute, fetch_one, get_connection
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
def _validate_url(url: str) -> str:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
url = "https://" + url.lstrip("/")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError("URL must start with http:// or https://")
return url
def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
"""Returns final_url, title, normalized_text, raw_html."""
try:
resp = httpx.get(
url,
timeout=25.0,
follow_redirects=True,
headers={"User-Agent": USER_AGENT, "Accept-Language": "nl-NL,nl;q=0.9"},
)
if resp.status_code >= 400:
return None
html = resp.text
soup = BeautifulSoup(html, "html.parser")
title = (soup.title.string or "").strip() if soup.title else ""
for tag in soup(["script", "style", "noscript", "svg", "iframe"]):
tag.decompose()
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
return str(resp.url), title, text, html
except Exception:
return None
def get_page_hash(url: str) -> str | None:
fetched = _fetch_page(url)
if not fetched:
return None
_, _, text, _ = fetched
return hashlib.md5(text.encode("utf-8")).hexdigest()
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
import json
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
site_id=EXCLUDED.site_id, crawled_at=NOW()
RETURNING id
""",
(
url,
final_url,
title,
text[:50000],
html[:100000],
site_id,
json.dumps({"source": "monitor"}),
),
)
page_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO browser_sessions (url, final_url, title, status, content_text, site_id, completed_at)
VALUES (%s,%s,%s,'completed',%s,%s,NOW()) RETURNING id
""",
(url, final_url, title, text[:80000], site_id),
)
session_id = cur.fetchone()[0]
cur.execute(
"UPDATE monitored_sites SET last_title=%s, last_snapshot_id=%s WHERE id=%s",
(title, session_id, site_id),
)
return session_id
except Exception:
return None
def add_site(url: str, name: str) -> dict:
url = _validate_url(url)
name = (name or url).strip()
fetched = _fetch_page(url)
if fetched:
final_url, title, text, html = fetched
h = hashlib.md5(text.encode("utf-8")).hexdigest()
else:
final_url, title, text, html = url, name, "", ""
h = hashlib.md5(url.encode("utf-8")).hexdigest()
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO monitored_sites (url, name, last_hash, last_crawled, last_title, is_active)
VALUES (%s, %s, %s, NOW(), %s, TRUE)
ON CONFLICT (url) DO UPDATE SET
name = EXCLUDED.name,
last_hash = EXCLUDED.last_hash,
last_crawled = NOW(),
last_title = EXCLUDED.last_title,
is_active = TRUE
RETURNING id
""",
(url, name, h, title),
)
site_id = cur.fetchone()[0]
if fetched:
_save_snapshot(site_id, url, final_url, title, text, html)
row = fetch_one(
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
(site_id,),
)
return dict(row) if row else {"id": site_id, "url": url, "name": name}
def remove_site(site_id: int, soft: bool = True) -> None:
if soft:
execute("UPDATE monitored_sites SET is_active = FALSE WHERE id = %s", (site_id,))
else:
execute("DELETE FROM crawl_logs WHERE site_id = %s", (site_id,))
execute("DELETE FROM page_changes WHERE site_id = %s", (site_id,))
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
def trigger_crawl(site_id: int | None = None) -> dict:
try:
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
return {"ok": True, "method": "worker"}
except Exception:
pass
from app.db import fetch_all
if site_id:
row = fetch_one(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [row] if row else []
else:
sites = fetch_all(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
)
changed = 0
with get_connection() as conn:
with conn.cursor() as cur:
for site in sites:
fetched = _fetch_page(site["url"])
if not fetched:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
)
continue
final_url, title, text, html = fetched
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
old_hash = site.get("last_hash")
if old_hash and old_hash != new_hash:
cur.execute(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site["id"], old_hash, new_hash),
)
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "CHANGE", f"Change detected on {site['url']}{title}"),
)
changed += 1
else:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "OK", f"Crawl OK — {title}"),
)
cur.execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(new_hash, title, site["id"]),
)
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import httpx
from app.config import settings
async def generate(prompt: str, system: str | None = None, timeout: float = 300.0) -> str:
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
return await chat_messages(messages, timeout=timeout)
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
payload = {
"model": settings.OLLAMA_MODEL,
"messages": messages,
"think": False,
"stream": False,
"keep_alive": "30m",
"options": {"num_predict": 280, "temperature": 0.4},
}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
msg = resp.json().get("message") or {}
content = (msg.get("content") or "").strip()
if content:
return content
thinking = (msg.get("thinking") or "").strip()
return thinking[:2000] if thinking else ""
+113
View File
@@ -0,0 +1,113 @@
"""Unified live platform feed — events with traceable sources."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all
CHANNEL_ROUTES = {
"dashboard": "/",
"retail": "/retail",
"marketing": "/marketing",
"beurs": "/beurs",
"agents": "/agents",
"hermes": "/hermes",
"browser": "/browser",
"documents": "/documents",
"settings": "/settings",
}
def _resolve_source(row: dict[str, Any]) -> dict[str, Any]:
meta = row.get("metadata") or {}
if isinstance(meta, str):
import json
try:
meta = json.loads(meta)
except Exception:
meta = {}
source_url = meta.get("source_url") or meta.get("url") or meta.get("link")
source_label = meta.get("source") or meta.get("feed_name")
if not source_url:
channel = row.get("channel") or "dashboard"
source_url = CHANNEL_ROUTES.get(channel, "/")
source_label = source_label or f"Foodlinkk · {channel}"
if row.get("related_table") == "rss_items" and row.get("related_id"):
source_url = meta.get("link") or source_url
event_type = (row.get("event_type") or "").lower()
agent = (row.get("agent_name") or "").lower()
if event_type in ("briefing", "report"):
source_url = "/"
elif event_type in ("sync", "score", "import") and "retail" in agent:
source_url = "/retail"
elif event_type == "refresh" and "rss" in agent:
source_url = "/marketing"
elif event_type in ("sync",) and "halal" in agent:
source_url = "/retail"
elif agent == "herman":
source_url = "/"
elif agent in ("marketing", "rss_feeds"):
source_url = "/marketing"
elif agent in ("wholesale_scraper", "retail_intel"):
source_url = "/retail"
elif agent == "hermes":
source_url = "/hermes"
internal_url = source_url if source_url.startswith("/") else None
external_url = source_url if source_url and source_url.startswith("http") else None
return {
"source_url": source_url,
"source_label": source_label or "Foodlinkk platform",
"internal_url": internal_url,
"external_url": external_url,
}
def fetch_platform_events(limit: int = 80, agent: Optional[str] = None) -> list[dict[str, Any]]:
clauses, params = [], []
if agent:
clauses.append("LOWER(agent_name) = %s")
params.append(agent.lower())
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""SELECT id, agent_name, agent_type, event_type, title, body, status,
channel, metadata, related_table, related_id, created_at
FROM agent_events{where}
ORDER BY created_at DESC LIMIT %s""",
tuple(params + [limit]),
)
events = []
for r in rows:
item = dict(r)
if item.get("created_at"):
item["created_at"] = item["created_at"].isoformat()
src = _resolve_source(item)
item.update(src)
item["click_url"] = src.get("external_url") or src.get("internal_url") or "/agents"
item["is_external"] = bool(src.get("external_url"))
events.append(item)
return events
def platform_stats() -> dict[str, Any]:
try:
total = fetch_all("SELECT COUNT(*) AS n FROM agent_events")[0]["n"]
pending = fetch_all("SELECT COUNT(*) AS n FROM agent_events WHERE status = 'needs_approval'")[0]["n"]
last_hour = fetch_all(
"SELECT COUNT(*) AS n FROM agent_events WHERE created_at >= NOW() - INTERVAL '1 hour'"
)[0]["n"]
except Exception:
total = pending = last_hour = 0
return {
"total_events": int(total or 0),
"pending_approvals": int(pending or 0),
"events_last_hour": int(last_hour or 0),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
+86
View File
@@ -0,0 +1,86 @@
"""Full-system data export for Reports hub."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Any
from app.db import fetch_all
EXPORT_DATASETS: dict[str, dict[str, str]] = {
"clients": {"label": "CRM Klanten", "table": "clients", "order": "updated_at DESC"},
"deals": {"label": "CRM Deals", "table": "deals", "order": "updated_at DESC"},
"supermarkets": {"label": "Supermarkten", "table": "supermarkets", "order": "name ASC"},
"wholesalers": {"label": "Groothandels", "table": "wholesalers", "order": "name ASC"},
"supermarket_contacts": {"label": "Supermarkt contacten", "table": "supermarket_contacts", "order": "id ASC"},
"wholesaler_contacts": {"label": "Groothandel contacten", "table": "wholesaler_contacts", "order": "id ASC"},
"rss_items": {"label": "RSS items", "table": "rss_items", "order": "published_at DESC NULLS LAST"},
"rss_bookmarks": {"label": "RSS bookmarks", "table": "rss_bookmarks", "order": "created_at DESC"},
"agent_events": {"label": "Agent events", "table": "agent_events", "order": "created_at DESC"},
"sales_milestones": {"label": "Sales milestones", "table": "sales_milestones", "order": "created_at DESC"},
"promo_campaigns": {"label": "Promo / reclame", "table": "promo_campaigns", "order": "created_at DESC"},
"daily_briefings": {"label": "Dagrapporten", "table": "daily_briefings", "order": "created_at DESC"},
"document_analytics": {"label": "NAS documenten", "table": "document_analytics", "order": "analyzed_at DESC NULLS LAST"},
"products": {"label": "Producten", "table": "products", "order": "name ASC"},
"suppliers": {"label": "Leveranciers", "table": "suppliers", "order": "name ASC"},
}
def _serialize(val: Any) -> Any:
if hasattr(val, "isoformat"):
return val.isoformat()
if isinstance(val, (dict, list)):
return json.dumps(val, default=str)
if val is not None and type(val).__name__ == "Decimal":
return float(val)
return val
def list_datasets() -> list[dict[str, Any]]:
out = []
for key, meta in EXPORT_DATASETS.items():
count = 0
try:
from app.db import fetch_one
row = fetch_one(f"SELECT COUNT(*) AS c FROM {meta['table']}")
count = int(row["c"]) if row else 0
except Exception:
pass
out.append({"id": key, "label": meta["label"], "count": count})
return out
def fetch_dataset(name: str, limit: int = 10000) -> list[dict[str, Any]]:
meta = EXPORT_DATASETS.get(name)
if not meta:
raise ValueError(f"Unknown dataset: {name}")
rows = fetch_all(f"SELECT * FROM {meta['table']} ORDER BY {meta['order']} LIMIT %s", (limit,))
for row in rows:
for k, v in list(row.items()):
row[k] = _serialize(v)
return rows
def to_csv(rows: list[dict[str, Any]]) -> str:
if not rows:
return ""
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue()
def export_all_json(limit: int = 5000) -> dict[str, Any]:
bundle: dict[str, Any] = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"datasets": {},
}
for key in EXPORT_DATASETS:
try:
bundle["datasets"][key] = fetch_dataset(key, limit=min(limit, 5000))
except Exception as exc:
bundle["datasets"][key] = {"error": str(exc)}
return bundle
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from app.db import execute, fetch_all, fetch_one
PLATFORMS = ("twitter", "linkedin", "instagram", "facebook", "tiktok", "pinterest")
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"twitter": ("api_key", "api_secret", "access_token", "access_secret"),
"linkedin": ("access_token", "person_urn"),
"instagram": ("access_token", "page_id"),
"facebook": ("access_token", "page_id"),
"tiktok": ("access_token", "open_id"),
"pinterest": ("access_token", "board_id"),
}
def _normalize_platform(platform: str) -> str:
value = (platform or "").strip().lower()
if value not in PLATFORMS:
raise ValueError(f"Unsupported platform: {platform}")
return value
def _serialize(value: Any) -> Any:
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _normalize_config(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {}
config = row.get("config") or {}
if isinstance(config, str):
try:
config = json.loads(config)
except Exception:
config = {}
if not isinstance(config, dict):
config = {}
# Keep compatibility with schemas that store fields as columns.
for key in ("api_key", "api_secret", "access_token", "access_secret", "person_urn", "page_id", "open_id", "board_id"):
if row.get(key) and not config.get(key):
config[key] = row.get(key)
return config
def _has_credentials(platform: str, config: dict[str, Any]) -> bool:
required = _REQUIRED_FIELDS.get(platform, ())
if not required:
return False
return all(bool(config.get(name)) for name in required)
def _log_event(title: str, body: str, status: str, metadata: 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)
""",
(
"marketing_automation",
"social_publish",
"social_publish",
title,
body[:2000],
status,
"marketing",
json.dumps(metadata),
),
)
except Exception:
pass
def get_integration(platform: str) -> dict[str, Any] | None:
platform = _normalize_platform(platform)
row = fetch_one(
"SELECT * FROM social_integrations WHERE platform = %s AND COALESCE(is_active, TRUE) = TRUE",
(platform,),
)
if not row:
return None
out = {k: _serialize(v) for k, v in row.items()}
out["platform"] = platform
out["config"] = _normalize_config(row)
return out
def get_configured_channels() -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM social_integrations WHERE platform = ANY(%s) ORDER BY platform",
(list(PLATFORMS),),
)
by_platform = {(row.get("platform") or "").lower(): row for row in rows}
items: list[dict[str, Any]] = []
for platform in PLATFORMS:
row = by_platform.get(platform)
config = _normalize_config(row)
items.append(
{
"platform": platform,
"configured": _has_credentials(platform, config),
"is_active": bool(row.get("is_active")) if row else False,
"updated_at": _serialize(row.get("updated_at")) if row else None,
}
)
return items
def publish_to_channel(platform: str, text: str, image_path: str | None = None, image_url: str | None = None) -> dict[str, Any]:
try:
platform = _normalize_platform(platform)
except ValueError as exc:
return {"status": "failed", "error": str(exc), "platform": platform}
integration = get_integration(platform)
if not integration:
return {
"status": "skipped_not_configured",
"error": f"{platform} integration is not configured",
"platform": platform,
}
config = integration.get("config") or {}
if not _has_credentials(platform, config):
return {
"status": "skipped_not_configured",
"error": f"Missing credentials for {platform}",
"platform": platform,
}
try:
if platform == "twitter":
try:
import tweepy # type: ignore
except Exception as exc:
return {"status": "failed_dependency", "platform": platform, "error": f"tweepy unavailable: {exc}"}
client = tweepy.Client(
consumer_key=config["api_key"],
consumer_secret=config["api_secret"],
access_token=config["access_token"],
access_token_secret=config["access_secret"],
)
resp = client.create_tweet(text=text[:280])
return {"status": "published", "platform": platform, "external_id": str(getattr(resp, "data", {}) or {})}
if platform == "linkedin":
import requests
payload = {
"author": config.get("person_urn"),
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": text},
"shareMediaCategory": "IMAGE" if image_url else "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
if image_url:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [{"status": "READY", "originalUrl": image_url}]
r = requests.post(
"https://api.linkedin.com/v2/ugcPosts",
headers={"Authorization": f"Bearer {config['access_token']}", "X-Restli-Protocol-Version": "2.0.0"},
json=payload,
timeout=20,
)
return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]}
if platform in ("instagram", "facebook"):
import requests
endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/feed"
payload = {"message": text, "access_token": config["access_token"]}
if image_url:
endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/photos"
payload = {"url": image_url, "caption": text, "access_token": config["access_token"]}
r = requests.post(endpoint, data=payload, timeout=20)
data = {}
try:
data = r.json()
except Exception:
data = {}
return {
"status": "published" if r.ok else "failed",
"platform": platform,
"external_id": data.get("id"),
"response_code": r.status_code,
"error": None if r.ok else (data.get("error", {}).get("message") or r.text[:300]),
}
if platform == "pinterest":
import requests
payload = {"board_id": config.get("board_id"), "title": text[:100], "description": text, "media_source": {"source_type": "image_url", "url": image_url}}
r = requests.post(
"https://api.pinterest.com/v5/pins",
headers={"Authorization": f"Bearer {config['access_token']}", "Content-Type": "application/json"},
json=payload,
timeout=20,
)
return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]}
if platform == "tiktok":
return {
"status": "failed",
"platform": platform,
"error": "TikTok publish placeholder not implemented yet (requires creator upload flow)",
}
except Exception as exc:
return {"status": "failed", "platform": platform, "error": str(exc)}
return {"status": "failed", "platform": platform, "error": "Unsupported platform"}
def test_connection(platform: str, integration: dict[str, Any] | None = None) -> dict[str, Any]:
platform = _normalize_platform(platform)
integration = integration or get_integration(platform)
if not integration:
return {"ok": False, "status": "skipped_not_configured", "error": f"{platform} integration is not configured"}
config = integration.get("config") or {}
if not _has_credentials(platform, config):
return {"ok": False, "status": "skipped_not_configured", "error": f"Missing credentials for {platform}"}
# Keep tests lightweight: perform a dry publish without side effects where possible.
if platform == "twitter":
try:
import tweepy # type: ignore
client = tweepy.Client(
consumer_key=config["api_key"],
consumer_secret=config["api_secret"],
access_token=config["access_token"],
access_token_secret=config["access_secret"],
)
_ = client.get_me()
return {"ok": True, "status": "ok", "message": "Twitter credentials look valid"}
except Exception as exc:
return {"ok": False, "status": "failed", "error": str(exc)}
return {"ok": True, "status": "ok", "message": f"{platform} configuration is present"}
def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str | None, media_ids: list[int]) -> None:
started_at = datetime.utcnow()
execute(
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s",
("running", job_id),
)
_log_event(
title=f"Social publish job #{job_id} gestart",
body=f"Kanalen: {', '.join(channels) if channels else '-'}",
status="running",
metadata={"job_id": job_id, "channels": channels},
)
chosen_image_url = image_url
if not chosen_image_url and media_ids:
media_rows = fetch_all(
"SELECT id, media_url, url, file_path FROM marketing_media WHERE id = ANY(%s) ORDER BY id",
(media_ids,),
)
if media_rows:
first = media_rows[0]
chosen_image_url = first.get("media_url") or first.get("url")
results: list[dict[str, Any]] = []
for channel in channels:
result = publish_to_channel(channel, text=text, image_url=chosen_image_url)
results.append(result)
published = sum(1 for item in results if item.get("status") == "published")
skipped = sum(1 for item in results if item.get("status") == "skipped_not_configured")
failed = len(results) - published - skipped
final_status = "completed"
if published == 0 and failed > 0:
final_status = "failed"
elif failed > 0:
final_status = "completed_with_errors"
execute(
"""
UPDATE social_publish_jobs
SET status=%s,
finished_at=NOW(),
updated_at=NOW(),
result=%s::jsonb
WHERE id=%s
""",
(
final_status,
json.dumps(
{
"published": published,
"skipped": skipped,
"failed": failed,
"channels": channels,
"results": results,
"started_at": started_at.isoformat(),
}
),
job_id,
),
)
_log_event(
title=f"Social publish job #{job_id} afgerond",
body=f"Published={published}, skipped={skipped}, failed={failed}",
status=final_status,
metadata={"job_id": job_id, "results": results},
)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
"bizdev": {"name": "BizDev", "persona": "Pipeline, retail partnerships, deal structuring."},
"finance": {"name": "Finance", "persona": "Margins, cashflow, pricing for food brands."},
"sourcing": {"name": "Sourcing", "persona": "Suppliers, MOQ, lead times, procurement."},
"product": {"name": "Product", "persona": "SKU development, launch timelines, shelf readiness."},
"halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."},
"design": {"name": "Design", "persona": "Packaging, visual identity, retail presentation."},
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
}
IMAGE_KEYWORDS = (
"maak foto", "maak een foto", "genereer foto", "genereer afbeelding",
"maak afbeelding", "productfoto", "genereer image", "generate image",
"make image", "maak plaatje", "/genfoto",
)
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in IMAGE_KEYWORDS)
def _extract_image_prompt(raw: str) -> str:
t = raw.strip()
lower = t.lower()
for k in IMAGE_KEYWORDS:
if lower.startswith(k):
rest = t[len(k):].strip(" :,-")
if rest:
return rest
for k in IMAGE_KEYWORDS:
if k in lower:
idx = lower.index(k) + len(k)
rest = t[idx:].strip(" :,-")
if rest:
return rest
return t
async def _log_event(agent_name: str, event_type: str, title: str, body: str, metadata: dict | None = None) -> None:
payload = {
"agent_name": agent_name,
"agent_type": "herman_delegate",
"event_type": event_type,
"title": title[:255],
"body": body,
"metadata": metadata or {},
"status": "completed",
"channel": "herman",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
await client.post(f"{settings.TOOLS_API_URL.rstrip('/')}/events", json=payload)
except Exception:
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(agent_name, "herman_delegate", event_type, title[:255], body, "completed", "herman", json.dumps(metadata or {})),
)
except Exception:
pass
def _pick_agent(raw: str) -> str:
text = (raw or "").strip().lower()
first = text.split()[0].replace(",", "").replace(".", "") if text else "knowledge"
if first in AGENTS:
return first
for k in AGENTS:
if k in text:
return k
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
if _wants_image(message):
prompt = _extract_image_prompt(message)
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
json={"prompt": prompt, "width": 512, "height": 512, "steps": 15},
)
r.raise_for_status()
data = r.json()
filename = data.get("filename", "")
subfolder = data.get("subfolder", "")
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
}
except Exception as exc:
return {
"agent": "design",
"agent_label": "Design",
"reply": f"Kon geen afbeelding genereren: {exc}",
}
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
await _log_event(
"herman",
"openswarm_delegation",
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "reason": data.get("routing_reason", "")},
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": data.get("routing_reason", ""),
}
except Exception as exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"Herman orchestrator niet bereikbaar: {exc}",
}
async def generate_briefing() -> str:
from app.services.briefing import generate_daily_briefing
content, stats = await generate_daily_briefing()
await _log_event("herman", "briefing", "CEO briefing generated", content[:1500], {"stats": stats})
return content
+218
View File
@@ -0,0 +1,218 @@
/* Hermes Neo Command Center — network monitor aesthetic */
.hm-neo { --hm-cyan: #00e5ff; --hm-mag: #ff2d95; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); }
.hm-neo-header h1 { font-size: 1.65rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.hm-neo-header .hm-sub { color: var(--hm-cyan); font-size: 0.85rem; margin: 0.35rem 0 0; opacity: 0.85; }
/* ── KPI cards (neo style) ── */
.hm-neo-kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 1.25rem 0;
}
@media (max-width: 1100px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .hm-neo-kpi-row { grid-template-columns: 1fr; } }
.hm-neo-kpi {
position: relative;
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1rem 1.1rem 0.75rem;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.hm-neo-kpi:hover { border-color: rgba(0, 229, 255, 0.35); box-shadow: 0 0 24px rgba(0, 229, 255, 0.08); }
.hm-neo-kpi.highlight { border-color: var(--hm-gold); box-shadow: 0 0 20px rgba(255, 215, 0, 0.12); }
.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.hm-neo-ring {
width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0);
display: grid; place-items: center;
box-shadow: 0 0 12px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 40%, transparent);
}
.hm-neo-ring-inner {
width: 32px; height: 32px; border-radius: 50%;
background: var(--hm-panel); display: grid; place-items: center;
font-size: 1rem;
}
.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; }
.hm-neo-kpi-value { font-size: 2rem; font-weight: 700; line-height: 1.1; color: #f1f5f9; font-variant-numeric: tabular-nums; }
.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; margin-top: 0.15rem; }
/* Animated equalizer bars */
.hm-eq {
display: flex; align-items: flex-end; justify-content: center; gap: 3px;
height: 28px; margin-top: 0.65rem; padding-top: 0.35rem;
border-top: 1px solid rgba(255,255,255,0.04);
}
.hm-eq span {
width: 5px; border-radius: 2px;
background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan));
animation: hmEq 0.55s ease-in-out infinite alternate;
box-shadow: 0 0 6px rgba(0, 229, 255, 0.35);
}
.hm-eq span:nth-child(1) { animation-delay: 0s; }
.hm-eq span:nth-child(2) { animation-delay: 0.08s; }
.hm-eq span:nth-child(3) { animation-delay: 0.16s; }
.hm-eq span:nth-child(4) { animation-delay: 0.24s; }
.hm-eq span:nth-child(5) { animation-delay: 0.32s; }
.hm-eq span:nth-child(6) { animation-delay: 0.12s; }
.hm-eq span:nth-child(7) { animation-delay: 0.2s; }
.hm-eq span:nth-child(8) { animation-delay: 0.28s; }
@keyframes hmEq {
from { height: 18%; opacity: 0.45; }
to { height: 92%; opacity: 1; }
}
/* ── Chart panels ── */
.hm-neo-charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } }
.hm-chart-panel {
background: var(--hm-panel);
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1rem 1.1rem;
min-height: 280px;
}
.hm-chart-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.75rem;
}
.hm-chart-head h3 {
margin: 0; font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: #94a3b8; font-weight: 600;
}
.hm-chart-head .hm-live-dot {
width: 8px; height: 8px; border-radius: 50%; background: #22c55e;
box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite;
}
@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.hm-chart-head small { color: #475569; font-size: 0.7rem; }
.hm-chart-wrap { position: relative; height: 220px; }
/* ── Tabs ── */
.hermes-tabs {
display: flex; flex-wrap: wrap; gap: 0.55rem;
margin: 0 0 1rem; padding: 0.45rem;
background: rgba(8, 12, 18, 0.8);
border: 1px solid var(--hm-border);
border-radius: 12px;
}
.hermes-tab {
flex: 1 1 auto; min-width: 120px;
padding: 0.8rem 1.1rem; font-size: 0.88rem; font-weight: 600;
border-radius: 10px; cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.25);
background: rgba(15, 23, 42, 0.7); color: #94a3b8;
transition: all 0.15s;
}
.hermes-tab:hover { border-color: rgba(0, 229, 255, 0.4); color: #e2e8f0; }
.hermes-tab.active {
background: linear-gradient(135deg, rgba(0, 229, 255, 0.12), rgba(255, 215, 0, 0.08));
border-color: var(--hm-gold); color: #fef9c3;
box-shadow: 0 0 18px rgba(255, 215, 0, 0.15);
}
/* ── Team roster (no duplicate names) ── */
.hm-team { margin-bottom: 1rem; }
.hm-team h3 { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #64748b; margin: 0 0 0.6rem; }
.hm-team-card {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.65rem 0.85rem; margin-bottom: 0.45rem;
background: rgba(13, 18, 25, 0.9);
border: 1px solid var(--hm-border);
border-radius: 10px;
}
.hm-team-card.online { border-left: 3px solid #22c55e; }
.hm-team-card .hm-role-badge {
min-width: 52px; text-align: center;
font-size: 0.65rem; font-weight: 800; letter-spacing: 0.08em;
padding: 0.25rem 0.4rem; border-radius: 6px;
}
.hm-team-card .hm-role-ceo { background: rgba(255, 215, 0, 0.15); color: var(--hm-gold); border: 1px solid rgba(255,215,0,0.3); }
.hm-team-card .hm-role-cto { background: rgba(0, 229, 255, 0.12); color: var(--hm-cyan); border: 1px solid rgba(0,229,255,0.25); }
.hm-team-card .hm-person { flex: 1; }
.hm-team-card .hm-person strong { display: block; font-size: 0.95rem; color: #f1f5f9; }
.hm-team-card .hm-person small { color: #64748b; font-size: 0.72rem; }
.hm-team-card .hm-status-dot { width: 10px; height: 10px; border-radius: 50%; }
.hm-team-card .hm-status-dot.on { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.hm-team-card .hm-status-dot.off { background: #475569; }
.hermes-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; }
@media (max-width: 900px) { .hermes-grid { grid-template-columns: 1fr; } }
.hermes-sidebar { max-height: 72vh; overflow-y: auto; }
.hermes-conv-btn {
display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left;
margin: 0.3rem 0; padding: 0.5rem 0.65rem;
background: rgba(13, 18, 25, 0.8); border: 1px solid var(--hm-border);
border-radius: 8px; color: #e2e8f0; cursor: pointer;
}
.hermes-conv-btn.active { border-color: var(--hm-cyan); box-shadow: 0 0 0 1px var(--hm-cyan); }
.hermes-conv-btn .conv-role { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.35rem; border-radius: 4px; }
.hermes-feed { max-height: 72vh; display: flex; flex-direction: column; }
.hermes-messages { overflow-y: auto; flex: 1; padding: 0.5rem; }
.hermes-msg {
margin-bottom: 0.65rem; padding: 0.6rem 0.8rem; border-radius: 8px;
border-left: 3px solid rgba(100,116,139,0.4);
background: rgba(13, 18, 25, 0.6);
}
.hermes-msg.msg-in { border-left-color: var(--hm-cyan); background: rgba(0, 229, 255, 0.04); }
.hermes-msg.msg-out { border-left-color: var(--hm-purple); background: rgba(168, 85, 247, 0.05); }
.hermes-msg-meta { font-size: 0.72rem; color: #64748b; display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.hermes-msg-body { white-space: pre-wrap; word-break: break-word; font-size: 0.88rem; }
/* PA live */
.hermes-pa-header {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
margin-bottom: 1rem; padding: 0.75rem 1rem;
background: rgba(0, 229, 255, 0.05); border: 1px solid var(--hm-border); border-radius: 10px;
}
.hermes-pa-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
@media (max-width: 900px) { .hermes-pa-grid { grid-template-columns: 1fr; } }
.hermes-pa-slot { border: 1px solid var(--hm-border); border-radius: 10px; overflow: hidden; background: var(--hm-panel); }
.hermes-pa-slot.status-loading { border-color: var(--hm-cyan); }
.hermes-pa-slot.status-completed { border-color: #22c55e; }
.hermes-pa-slot.status-failed { border-color: #ef4444; }
.hermes-pa-slot-head { display: flex; justify-content: space-between; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.3); }
.hermes-pa-slot-body { height: 200px; background: #060a10; display: grid; place-items: center; }
.hermes-pa-slot-body img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.hermes-pa-placeholder { color: #64748b; font-size: 0.82rem; text-align: center; padding: 1rem; }
.hermes-pa-slot-foot { padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-badge { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 4px; }
.status-badge.loading { background: rgba(0,229,255,0.15); color: var(--hm-cyan); }
.status-badge.completed { background: rgba(34,197,94,0.15); color: #22c55e; }
.status-badge.failed { background: rgba(239,68,68,0.12); color: #ef4444; }
.status-badge.waiting, .status-badge.idle { background: rgba(100,116,139,0.15); color: #94a3b8; }
.status-badge.running, .status-badge.comparing { background: rgba(255,159,67,0.15); color: var(--hm-orange); }
.hermes-graph-canvas { height: 480px; border: 1px solid var(--hm-border); border-radius: 10px; background: #060a10; }
.hermes-search-bar { display: flex; gap: 0.5rem; margin: 1rem 0; }
.hermes-search-bar .form-input { flex: 1; }
.hermes-search-hit { padding: 0.65rem; border-bottom: 1px solid var(--hm-border); }
.hermes-control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.hermes-info-list { list-style: none; padding: 0; }
.hermes-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.tag { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.78rem; }
.tag-ok { background: rgba(34,197,94,0.15); color: #22c55e; }
.tag-block { background: rgba(239,68,68,0.12); color: #ef4444; }
.tag-pa { background: rgba(168,85,247,0.15); color: var(--hm-purple); }
.hermes-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
.hermes-events { max-height: 360px; overflow-y: auto; }
.hermes-event { padding: 0.45rem 0; border-bottom: 1px solid var(--hm-border); }
.panel-header { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; margin-bottom: 0.65rem; }
.hermes-select { max-width: 200px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.dot-in { background: var(--hm-cyan); }
.dot-out { background: var(--hm-purple); }
.dot-edge { background: var(--hm-orange); }
+244
View File
@@ -0,0 +1,244 @@
{% extends "base.html" %}
{% block title %}Hermes · Telegram Command Center{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/hermes.css" />
<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">
<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>
</header>
<!-- Neo KPI row -->
<div class="hm-neo-kpi-row">
<div class="hm-neo-kpi" :class="{ highlight: tab === 'feed' }">
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</div>
<!-- Neo charts -->
<div class="hm-neo-charts">
<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>
</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>
<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>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
from pathlib import Path
p = Path("/home/aissa/foodlinkk-command-center/cockpit/app/routes/admin_api.py")
text = p.read_text()
if "reco_proxy" not in text:
text = "from app.routes.reco_proxy import register_recommendation_routes\n" + text
if "register_recommendation_routes(admin_router)" not in text:
text += "\nregister_recommendation_routes(admin_router)\n"
p.write_text(text)
print("admin_api patched")
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
from pathlib import Path
# cockpit main.py - add retail router
main = Path("/home/aissa/foodlinkk-command-center/cockpit/app/main.py")
text = main.read_text()
if "retail" not in text:
text = text.replace(
" browser,\n hermes,\n)",
" browser,\n hermes,\n retail,\n)",
)
text = text.replace(
"from app.routes import (",
"from app.routes import (\n retail,",
)
if "retail.router," not in text:
text = text.replace(
" marketing.router,\n",
" marketing.router,\n retail.router,\n",
)
main.write_text(text)
# base.html - add Retail link under Intel
base = Path("/home/aissa/foodlinkk-command-center/cockpit/templates/base.html")
b = base.read_text()
if "/retail" not in b:
b = b.replace(
'<a href="/browser"',
'<a href="/retail" class="{% if request.url.path.startswith(\'/retail\') %}active{% endif %}">Retail</a>\n <a href="/browser"',
)
base.write_text(b)
# marketing.html - add Herman tab
m = Path("/home/aissa/foodlinkk-command-center/cockpit/templates/marketing.html")
mt = m.read_text()
if "herman-reco" not in mt:
mt = mt.replace(
'<button type="button" :class="{ active: tab===\'analytics\' }" @click="tab=\'analytics\'">Analytics</button>',
'<button type="button" :class="{ active: tab===\'analytics\' }" @click="tab=\'analytics\'">Analytics</button>\n <button type="button" :class="{ active: tab===\'herman\' }" @click="tab=\'herman\'">Herman</button>',
)
mt += '''
<section class="panel" x-show="tab==='herman'" x-cloak>
<h2>Herman Recommendations</h2>
<div id="reco-list">Loading…</div>
<script>
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
const el=document.getElementById('reco-list');
el.innerHTML=(d.items||[]).map(i=>`<div class="panel" style="margin:.5rem 0"><strong>${i.title}</strong><p>${i.description||''}</p>
<button class="btn btn-sm btn-approve" onclick="fetch('/api/admin/recommendations/${i.id}/approve',{method:'POST'}).then(()=>location.reload())">Approve</button>
<button class="btn btn-sm btn-reject" onclick="fetch('/api/admin/recommendations/${i.id}/dismiss',{method:'POST'}).then(()=>location.reload())">Dismiss</button></div>`).join('')||'<p>Geen open recommendations</p>';
});
</script>
</section>
'''
m.write_text(mt)
print("cockpit patched")
+10
View File
@@ -0,0 +1,10 @@
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
tweepy==4.15.0
+310
View File
@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block content %}
<div x-data="settingsPage()" x-init="load()">
<header class="page-header settings-header">
<div>
<h1>Settings</h1>
<p class="subtitle page-tip">Configure integrations — Herman reads active settings from PostgreSQL.</p>
</div>
</header>
<nav class="settings-tabs">
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
</nav>
{% if active_tab == 'email' %}
<section class="panel settings-panel">
<div class="settings-panel-head">
<div>
<h2>Email accounts</h2>
<p class="page-tip">Kies welk mailbox Herman gebruikt om te versturen. IMAP velden zijn voor latere inbox-sync.</p>
</div>
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuw account</button>
</div>
<div class="settings-active-banner" x-show="activeAccount" x-cloak>
<span class="badge badge-positive">Actief</span>
<span x-text="activeAccount ? activeAccount.label + ' · ' + activeAccount.email_address : ''"></span>
</div>
<div class="email-account-list">
<template x-for="acc in accounts" :key="acc.id">
<article class="email-account-card" :class="{ 'is-active': acc.is_active }">
<div class="email-account-card-head">
<div>
<strong x-text="acc.label"></strong>
<span class="muted" x-text="acc.email_address"></span>
</div>
<span class="badge" :class="acc.is_active ? 'badge-positive' : 'badge-neutral'" x-text="acc.is_active ? 'Actief' : acc.provider"></span>
</div>
<div class="email-account-meta">
<span x-text="'SMTP: ' + (acc.smtp_host || '—') + ':' + (acc.smtp_port || '')"></span>
<span x-text="acc.smtp_password_set ? '🔒 wachtwoord opgeslagen' : '⚠️ geen SMTP wachtwoord'"></span>
<span x-show="acc.last_test_status" x-text="'Test: ' + acc.last_test_status"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-sm" @click="edit(acc)">Bewerken</button>
<button type="button" class="btn btn-primary btn-sm" @click="activate(acc.id)" x-show="!acc.is_active">Maak actief</button>
<button type="button" class="btn btn-secondary btn-sm" @click="testAccount(acc.id)">Test SMTP</button>
<button type="button" class="btn btn-reject btn-sm" @click="remove(acc.id)">Verwijder</button>
</div>
</article>
</template>
<p class="empty-state" x-show="!accounts.length && !loading">Nog geen email account — voeg er een toe om Herman mail te laten versturen.</p>
</div>
</section>
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
<div class="modal-card settings-modal">
<h3 x-text="form.id ? 'Email account bewerken' : 'Nieuw email account'"></h3>
<label class="form-label">Label
<input class="form-input" x-model="form.label" placeholder="bv. Foodlinkk sales" />
</label>
<label class="form-label">Email adres
<input class="form-input" type="email" x-model="form.email_address" placeholder="info@foodlinkk.nl" />
</label>
<label class="form-label">Provider (vult SMTP/IMAP automatisch in)
<select class="form-input" x-model="form.provider" @change="applyProvider()">
<option value="custom">Custom / eigen server</option>
<option value="gmail">Gmail</option>
<option value="outlook">Outlook / Microsoft 365</option>
<option value="zoho">Zoho Mail</option>
</select>
</label>
<div class="settings-form-grid">
<label class="form-label">SMTP host<input class="form-input" x-model="form.smtp_host" /></label>
<label class="form-label">SMTP port<input class="form-input" type="number" x-model.number="form.smtp_port" /></label>
<label class="form-label">SMTP user<input class="form-input" x-model="form.smtp_user" /></label>
<label class="form-label">SMTP wachtwoord
<input class="form-input" type="password" x-model="form.smtp_password" :placeholder="form.smtp_password_set ? '•••••••• (laat leeg om te behouden)' : 'App-wachtwoord'" />
</label>
</div>
<details class="settings-details">
<summary>IMAP (inbox sync — later)</summary>
<div class="settings-form-grid">
<label class="form-label">IMAP host<input class="form-input" x-model="form.imap_host" /></label>
<label class="form-label">IMAP port<input class="form-input" type="number" x-model.number="form.imap_port" /></label>
<label class="form-label">IMAP user<input class="form-input" x-model="form.imap_user" /></label>
<label class="form-label">IMAP wachtwoord
<input class="form-input" type="password" x-model="form.imap_password" :placeholder="form.imap_password_set ? '••••••••' : 'App-wachtwoord'" />
</label>
</div>
<label class="form-check">
<input type="checkbox" x-model="form.sync_enabled" /> Inbox sync inschakelen (wanneer beschikbaar)
</label>
</details>
<label class="form-check">
<input type="checkbox" x-model="form.is_active" /> Direct als actief account instellen
</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 SMTP</button>
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleren</button>
</div>
</div>
</div>
{% elif active_tab == 'permissions' %}
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
<div class="settings-panel-head">
<div>
<h2>Herman — Module rechten</h2>
<p class="page-tip">Bepaal welke bedrijfsprocessen Herman mag benaderen. Grant all = volledige toegang.</p>
</div>
<button type="button" class="btn btn-pulse-green" @click="grantAll()">👑 Alles aan Herman</button>
</div>
<p><span class="live-badge">Live</span> <span x-text="grantedCount + ' / ' + total + ' modules actief'"></span></p>
<div class="perm-grid">
<template x-for="p in perms" :key="p.module_key">
<div class="perm-card" :class="p.granted ? 'granted' : ''">
<div>
<strong x-text="p.module_label"></strong>
<p style="font-size:0.8rem;margin:0.25rem 0;color:#94a3b8" x-text="p.description"></p>
<small x-text="p.category"></small>
</div>
<label class="form-check">
<input type="checkbox" :checked="p.granted" @change="toggle(p.module_key, $event.target.checked)"> Toegang
</label>
</div>
</template>
</div>
</section>
{% elif active_tab == 'general' %}
<section class="panel settings-panel">
<h2>General</h2>
<p class="page-tip">Dashboard: <a href="http://10.4.7.18:8600">http://10.4.7.18:8600</a> · Telegram: @klaploper_bot · Ochtend briefing: 07:00</p>
<p class="page-tip">Meer instellingen (Telegram tijd, timezone) komen hier later.</p>
</section>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function permissionsPage() {
return {
perms: [], grantedCount: 0, total: 0,
async loadPerms() {
const r = await Cockpit.api('/api/settings/permissions');
this.perms = r.items || [];
this.grantedCount = r.granted_count || 0;
this.total = r.total || 0;
},
async toggle(key, granted) {
await Cockpit.api('/api/settings/permissions/' + key, { method: 'PUT', body: JSON.stringify({ granted }) });
await this.loadPerms();
},
async grantAll() {
const r = await Cockpit.api('/api/settings/permissions/grant-all', { method: 'POST' });
Cockpit.toast(r.message || 'Alle rechten toegekend', 'success');
await this.loadPerms();
}
};
}
</script>
{% if active_tab == 'email' %}
<script>
const PROVIDERS = {
gmail: { smtp_host: 'smtp.gmail.com', smtp_port: 587, imap_host: 'imap.gmail.com', imap_port: 993 },
outlook: { smtp_host: 'smtp.office365.com', smtp_port: 587, imap_host: 'outlook.office365.com', imap_port: 993 },
zoho: { smtp_host: 'smtp.zoho.eu', smtp_port: 587, imap_host: 'imap.zoho.eu', imap_port: 993 },
custom: { smtp_host: '', smtp_port: 587, imap_host: '', imap_port: 993 },
};
function settingsPage() {
return {
accounts: [],
activeAccount: null,
loading: true,
formOpen: false,
saving: false,
form: defaultForm(),
async load() {
this.loading = true;
try {
const r = await Cockpit.api('/api/settings/email');
this.accounts = r.accounts || [];
this.activeAccount = this.accounts.find(a => a.is_active) || null;
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.loading = false;
},
defaultForm() { return defaultForm(); },
openForm() {
this.form = defaultForm();
this.formOpen = true;
},
edit(acc) {
this.form = Object.assign(defaultForm(), acc, { smtp_password: '', imap_password: '' });
this.formOpen = true;
},
applyProvider() {
const p = PROVIDERS[this.form.provider] || PROVIDERS.custom;
this.form.smtp_host = p.smtp_host;
this.form.smtp_port = p.smtp_port;
this.form.imap_host = p.imap_host;
this.form.imap_port = p.imap_port;
if (this.form.email_address && !this.form.smtp_user) {
this.form.smtp_user = this.form.email_address;
this.form.imap_user = this.form.email_address;
}
},
bodyFromForm() {
const b = {
label: this.form.label,
email_address: this.form.email_address,
provider: this.form.provider,
is_active: this.form.is_active,
smtp_host: this.form.smtp_host,
smtp_port: this.form.smtp_port,
smtp_user: this.form.smtp_user,
imap_host: this.form.imap_host,
imap_port: this.form.imap_port,
imap_user: this.form.imap_user,
sync_enabled: this.form.sync_enabled,
};
if (this.form.smtp_password) b.smtp_password = this.form.smtp_password;
if (this.form.imap_password) b.imap_password = this.form.imap_password;
return b;
},
async save() {
this.saving = true;
try {
const body = this.bodyFromForm();
if (this.form.id) {
await Cockpit.api('/api/settings/email/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/api/settings/email', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('Opgeslagen', 'success');
this.formOpen = false;
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.saving = false;
},
async activate(id) {
try {
await Cockpit.api('/api/settings/email/' + id + '/activate', { method: 'POST' });
Cockpit.toast('Actief account gewijzigd', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async testAccount(id) {
try {
Cockpit.toast('SMTP test…', 'info');
const r = await Cockpit.api('/api/settings/email/' + id + '/test', { method: 'POST' });
Cockpit.toast(r.message || 'SMTP OK', r.ok ? 'success' : 'error');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async testForm() {
this.saving = true;
try {
const r = await Cockpit.api('/api/settings/email/test', {
method: 'POST',
body: JSON.stringify(this.bodyFromForm()),
});
Cockpit.toast(r.message || 'SMTP OK', r.ok ? 'success' : 'error');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.saving = false;
},
async remove(id) {
if (!confirm('Email account verwijderen?')) return;
try {
await Cockpit.api('/api/settings/email/' + id, { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
function defaultForm() {
return {
id: null, label: '', email_address: '', provider: 'gmail', is_active: true,
smtp_host: 'smtp.gmail.com', smtp_port: 587, smtp_user: '', smtp_password: '', smtp_password_set: false,
imap_host: 'imap.gmail.com', imap_port: 993, imap_user: '', imap_password: '', imap_password_set: false,
sync_enabled: false,
};
}
</script>
{% endif %}
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
.mesh-wrap {
position: relative;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
overflow: hidden;
}
.mesh-svg {
width: 100%;
min-height: 520px;
display: block;
}
.mesh-edge {
stroke: rgba(0, 229, 255, 0.42);
stroke-width: 1.6;
fill: none;
stroke-linecap: round;
}
.mesh-pulse {
stroke: rgba(0, 229, 255, 0.9);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 3 140;
animation: mesh-pulse 2.4s linear infinite;
}
@keyframes mesh-pulse {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -286; }
}
.mesh-node { cursor: pointer; }
.mesh-node text {
font-size: 12px;
fill: #e2e8f0;
font-weight: 600;
text-anchor: middle;
}
.mesh-node circle {
fill: #0f172a;
stroke: rgba(148, 163, 184, 0.65);
stroke-width: 2;
}
.mesh-node.healthy circle { stroke: #22c55e; }
.mesh-node.warn circle { stroke: #f59e0b; }
.mesh-node.idle circle { stroke: #38bdf8; }
.mesh-node.offline circle { stroke: #64748b; }
.mesh-herman circle.main {
fill: #111827;
stroke: #ffd700;
stroke-width: 4;
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
}
.mesh-herman circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.35);
stroke-width: 2;
stroke-dasharray: 8 4;
animation: mesh-herman-ring 7s linear infinite;
transform-origin: center;
}
@keyframes mesh-herman-ring {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.mesh-legend {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
margin-top: 0.75rem;
color: #94a3b8;
font-size: 0.82rem;
}
+50
View File
@@ -0,0 +1,50 @@
.analytics-shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
}
.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;
margin-bottom: 1rem;
}
.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);
}
.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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.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; }
}
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ page_title }} · Foodlinkk{% endblock %}</title>
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=2" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block extra_head %}{% endblock %}
</head>
<body x-data="{ drawerOpen: false }" :class="{ 'drawer-open': drawerOpen }">
<header class="topbar">
<div class="topbar-brand">
<p class="brand-title">Foodlinkk</p>
<p class="brand-name">Command Center</p>
</div>
<nav class="topnav">
<a href="/" class="nav-pill nav-pill-herman topnav-herman {% if request.url.path == '/' %}active{% endif %}">
Herman
</a>
<div class="topnav-group topnav-group-crm">
<span class="topnav-label">CRM</span>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
</div>
<div class="topnav-group topnav-group-intel">
<span class="topnav-label">Intel</span>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser &amp; Monitor</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
</div>
<div class="topnav-group topnav-group-hermes">
<span class="topnav-label">Hermes</span>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
</div>
<div class="topnav-group topnav-group-agents">
<span class="topnav-label">Agents</span>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
</div>
<div class="topnav-group topnav-group-system">
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
</div>
</nav>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
<script src="/static/js/cockpit.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
/* Beurs & Live Intel page */
.beurs-shell { max-width: 1400px; margin: 0 auto; }
.beurs-hero {
display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem;
background: linear-gradient(135deg, rgba(56,189,248,0.14), rgba(74,222,128,0.08), rgba(251,146,60,0.06));
border: 1px solid rgba(56,189,248,0.35);
border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
box-shadow: 0 0 30px rgba(56,189,248,0.08);
}
.beurs-hero h1 { margin: 0 0 0.35rem; font-size: 1.65rem; color: #f8fafc; }
.beurs-hero .hero-sub { color: #94a3b8; margin: 0 0 0.35rem; }
.beurs-hero small { color: #64748b; }
.beurs-hero-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* Neo tab bar — interactive */
.neo-tab-bar {
position: relative;
display: flex;
gap: 0;
background: rgba(10, 14, 22, 0.9);
border: 1px solid rgba(56,189,248,0.2);
border-radius: 14px;
padding: 0.35rem;
margin-bottom: 1.25rem;
overflow: hidden;
}
.neo-tab {
flex: 1;
display: flex; align-items: center; justify-content: center; gap: 0.45rem;
padding: 0.75rem 0.5rem;
border: none; background: transparent;
color: #94a3b8; font-size: 0.82rem; font-weight: 600;
cursor: pointer; position: relative; z-index: 1;
transition: color 0.25s, transform 0.2s;
letter-spacing: 0.02em;
}
.neo-tab:hover { color: #e2e8f0; transform: translateY(-1px); }
.neo-tab.active { color: #f8fafc; text-shadow: 0 0 20px rgba(56,189,248,0.5); }
.neo-tab-icon { font-size: 1.1rem; }
.neo-tab-badge {
background: #fb7185; color: #fff; font-size: 0.6rem;
padding: 0.1rem 0.35rem; border-radius: 999px; min-width: 1rem;
}
.neo-tab-indicator {
position: absolute; bottom: 0.35rem; top: 0.35rem;
background: linear-gradient(135deg, rgba(56,189,248,0.25), rgba(74,222,128,0.12));
border: 1px solid rgba(56,189,248,0.45);
border-radius: 10px;
box-shadow: 0 0 24px rgba(56,189,248,0.2);
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none; z-index: 0;
}
.beurs-section { animation: fade-up 0.35s ease; }
.section-label {
font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase;
color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem;
}
.beurs-summary-row {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.25rem;
}
@media (max-width: 800px) { .beurs-summary-row { grid-template-columns: 1fr 1fr; } }
.beurs-stat-card {
background: rgba(15, 23, 42, 0.8); border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; padding: 0.75rem 1rem;
}
.beurs-stat-card span { display: block; font-size: 0.7rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
.beurs-stat-card strong { font-size: 1.35rem; color: #f8fafc; }
.beurs-stat-card strong.up { color: #4ade80; }
.beurs-stat-card strong.down { color: #fb7185; }
.beurs-stat-card.warn strong { color: #fb923c; }
.beurs-stat-card.source-card a { color: #38bdf8; text-decoration: none; font-size: 0.95rem; }
.beurs-stat-card.source-card a:hover { text-decoration: underline; }
.beurs-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.85rem;
}
.beurs-card {
background: linear-gradient(160deg, rgba(13,18,25,0.95), rgba(8,12,18,0.98));
border: 1px solid rgba(56,189,248,0.15); border-radius: 14px;
padding: 0.85rem 1rem; transition: border-color 0.2s, box-shadow 0.2s;
}
.beurs-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 20px rgba(56,189,248,0.1); }
.beurs-card.trend-up { border-color: rgba(74,222,128,0.25); }
.beurs-card.trend-down { border-color: rgba(251,113,133,0.25); }
.beurs-card.unlisted-card { border-color: rgba(148,163,184,0.2); opacity: 0.92; }
.beurs-card-head { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.35rem; }
.beurs-chain-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 0.35rem; flex-shrink: 0; box-shadow: 0 0 8px currentColor; }
.beurs-card-head strong { display: block; color: #f1f5f9; font-size: 0.95rem; }
.beurs-card-head small { color: #64748b; font-size: 0.7rem; }
.beurs-pct { margin-left: auto; font-size: 0.75rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 6px; }
.beurs-pct.up { color: #4ade80; background: rgba(74,222,128,0.12); }
.beurs-pct.down { color: #fb7185; background: rgba(251,113,133,0.12); }
.beurs-tag { margin-left: auto; font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 6px; background: rgba(148,163,184,0.15); color: #94a3b8; }
.beurs-chains { font-size: 0.72rem; color: #64748b; margin-bottom: 0.4rem; }
.beurs-price { font-size: 1.2rem; font-weight: 700; color: #f8fafc; margin-bottom: 0.35rem; }
.beurs-price small { font-size: 0.65rem; color: #94a3b8; margin-left: 0.25rem; }
.market-state { font-size: 0.6rem; color: #4ade80; margin-left: 0.5rem; text-transform: uppercase; }
.beurs-spark { width: 100%; height: 30px; display: block; margin: 0.25rem 0; }
.beurs-eq {
display: flex; align-items: flex-end; justify-content: center; gap: 3px;
height: 22px; margin: 0.35rem 0;
}
.beurs-eq span {
width: 4px; border-radius: 2px;
background: linear-gradient(to top, rgba(56,189,248,0.3), #38bdf8);
animation: hmEq 0.55s ease-in-out infinite alternate;
}
.beurs-eq span:nth-child(odd) { animation-delay: 0.1s; }
.beurs-sources { display: flex; justify-content: space-between; align-items: center; margin-top: 0.35rem; }
.beurs-sources small { font-size: 0.65rem; color: #64748b; }
.unlisted-note { font-size: 0.78rem; color: #94a3b8; margin: 0.35rem 0; }
.beurs-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
@media (max-width: 900px) { .beurs-two-col { grid-template-columns: 1fr; } }
.panel-inner {
background: rgba(15,23,42,0.6); border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px; padding: 1rem 1.15rem;
}
.panel-inner h2 { margin: 0 0 0.75rem; font-size: 1rem; color: #f1f5f9; }
.tag { display: inline-block; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; padding: 0.12rem 0.4rem; border-radius: 4px; margin-bottom: 0.35rem; }
.tag-halal { background: rgba(74,222,128,0.15); color: #4ade80; }
.tag-dish { background: rgba(56,189,248,0.15); color: #38bdf8; }
.tag-trend { background: rgba(251,146,60,0.15); color: #fb923c; }
.concept-header { margin-bottom: 1rem; }
.concept-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.concept-card {
background: linear-gradient(135deg, rgba(192,132,252,0.08), rgba(56,189,248,0.06));
border: 1px solid rgba(192,132,252,0.25); border-radius: 14px; padding: 1rem 1.15rem;
}
.concept-num { font-size: 0.65rem; color: #c084fc; font-weight: 700; }
.concept-card p { color: #e2e8f0; line-height: 1.55; margin: 0.5rem 0; font-size: 0.92rem; }
.concept-based small { color: #64748b; font-size: 0.72rem; }
.events-toolbar { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.75rem; }
.events-toolbar select { max-width: 220px; }
.events-stream { display: flex; flex-direction: column; gap: 0.35rem; }
.event-row {
display: grid; grid-template-columns: 52px 100px 1fr 90px 80px; gap: 0.5rem; align-items: center;
padding: 0.65rem 0.85rem; border-radius: 10px;
background: rgba(15,23,42,0.7); border: 1px solid rgba(255,255,255,0.06);
text-decoration: none; color: inherit; transition: all 0.2s;
}
.event-row:hover {
border-color: rgba(56,189,248,0.4); background: rgba(56,189,248,0.08);
transform: translateX(4px);
}
.event-time { font-size: 0.75rem; color: #64748b; font-variant-numeric: tabular-nums; }
.event-agent { font-size: 0.7rem; }
.event-title { font-size: 0.85rem; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.event-status { font-size: 0.65rem; text-transform: uppercase; color: #94a3b8; }
.event-status.st-needs_approval { color: #fb923c; }
.event-status.st-completed { color: #4ade80; }
.event-source { font-size: 0.65rem; color: #38bdf8; text-align: right; }
@media (max-width: 700px) {
.event-row { grid-template-columns: 1fr; gap: 0.25rem; }
}
.topnav-beurs.active { color: #4ade80 !important; text-shadow: 0 0 12px rgba(74,222,128,0.4); }
@keyframes hmEq {
from { height: 20%; opacity: 0.5; }
to { height: 90%; opacity: 1; }
}
+378
View File
@@ -0,0 +1,378 @@
/* Herman dashboard — Hermes neo style (KPI cards animate, grote panelen vast) */
.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); }
.herman-shell.hm-neo .herman-hero {
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
/* GEEN animatie op hero — vast */
}
.herman-shell.hm-neo .herman-hero h1 { font-size: 1.65rem; margin: 0; }
.herman-shell.hm-neo .hero-sub { color: var(--hm-cyan); opacity: 0.9; }
/* Dashboard layout sections */
.herman-briefing-header {
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem;
margin-bottom: 1.25rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--hm-border);
}
.herman-briefing-header h2 { margin: 0; }
.herman-briefing-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.hm-dash-layout { display: flex; flex-direction: column; gap: 1.5rem; }
.hm-dash-section { display: flex; flex-direction: column; gap: 0.75rem; }
.hm-dash-full { width: 100%; }
.hm-section-title {
margin: 0; font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase;
color: #94a3b8; font-weight: 600; display: flex; align-items: center; gap: 0.5rem;
padding-bottom: 0.35rem; border-bottom: 1px solid rgba(255,255,255,0.05);
}
.hm-dash-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } }
.briefing-summary-grid {
display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem;
}
.briefing-horizon-col { display: grid; gap: 1rem; }
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
.briefing-card.summary,
.horizon-card {
background: var(--hm-panel); border-radius: 14px; padding: 1rem;
}
.briefing-card.summary { border: 1px solid var(--hm-border); }
.briefing-card.summary h4,
.horizon-card h4 {
margin: 0 0 0.65rem; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase;
}
.horizon-card.short { border: 1px solid rgba(255,159,67,0.3); }
.horizon-card.short h4 { color: #ff9f43; }
.horizon-card.long { border: 1px solid rgba(168,85,247,0.3); }
.horizon-card.long h4 { color: #a855f7; }
.hm-dash-trends-only { margin-top: 0; max-width: 100%; }
.hm-dash-trends-only .hm-chart-wrap { height: 180px; }
.beurs-cta-panel {
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;
background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.25rem;
}
.beurs-mini-row { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: flex-start; }
.beurs-mini-chip {
display: inline-block; padding: 0.35rem 0.65rem; border-radius: 8px; text-decoration: none;
font-size: 0.8rem; border: 1px solid rgba(255,255,255,0.1); color: #e2e8f0;
}
.beurs-mini-chip.up { border-color: rgba(74,222,128,0.3); background: rgba(74,222,128,0.08); }
.beurs-mini-chip.down { border-color: rgba(251,113,133,0.3); background: rgba(251,113,133,0.08); }
.beurs-mini-chip strong { color: #f8fafc; }
.hm-dash-feed { margin-top: 1rem; }
.hm-chart-head h4 {
margin: 0; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase;
color: #cbd5e1; font-weight: 600;
}
/* Grote briefing panel — VAST, geen beweging */
.herman-shell .herman-briefing {
position: relative;
border-left: 3px solid var(--hm-cyan);
animation: none !important;
transform: none !important;
}
.herman-shell .briefing-card,
.herman-shell .briefing-chart-panel,
.herman-shell .horizon-card,
.herman-shell .hub-grid .panel {
animation: none !important;
}
/* Neo KPI row — per kaart animatie (Hermes stijl) */
.hm-neo-kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
margin: 1rem 0 1.25rem;
}
@media (max-width: 1200px) { .hm-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 700px) { .hm-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
.hm-neo-kpi {
position: relative;
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1rem 1.1rem 0.75rem;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.hm-neo-kpi:hover {
border-color: rgba(0, 229, 255, 0.35);
box-shadow: 0 0 24px rgba(0, 229, 255, 0.1);
}
.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.35rem; }
.hm-neo-ring {
width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0);
display: grid; place-items: center;
box-shadow: 0 0 14px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 45%, transparent);
animation: hmRingSpin 8s linear infinite;
}
@keyframes hmRingSpin {
0% { filter: brightness(1); }
50% { filter: brightness(1.2); }
100% { filter: brightness(1); }
}
.hm-neo-ring-inner {
width: 32px; height: 32px; border-radius: 50%;
background: var(--hm-panel); display: grid; place-items: center; font-size: 1rem;
}
.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #94a3b8; }
.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; }
.hm-neo-kpi-value { font-size: 1.75rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.hm-eq {
display: flex; align-items: flex-end; justify-content: center; gap: 3px;
height: 26px; margin-top: 0.55rem; padding-top: 0.35rem;
border-top: 1px solid rgba(255,255,255,0.04);
}
.hm-eq span {
width: 5px; border-radius: 2px;
background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan));
animation: hmEq 0.55s ease-in-out infinite alternate;
box-shadow: 0 0 6px rgba(0, 229, 255, 0.35);
}
.hm-eq span:nth-child(1) { animation-delay: 0s; }
.hm-eq span:nth-child(2) { animation-delay: 0.08s; }
.hm-eq span:nth-child(3) { animation-delay: 0.16s; }
.hm-eq span:nth-child(4) { animation-delay: 0.24s; }
.hm-eq span:nth-child(5) { animation-delay: 0.32s; }
.hm-eq span:nth-child(6) { animation-delay: 0.12s; }
.hm-eq span:nth-child(7) { animation-delay: 0.2s; }
.hm-eq span:nth-child(8) { animation-delay: 0.28s; }
@keyframes hmEq {
from { height: 18%; opacity: 0.45; }
to { height: 92%; opacity: 1; }
}
.hm-neo-charts {
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;
}
@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } }
.hm-chart-panel {
background: var(--hm-panel); border: 1px solid var(--hm-border);
border-radius: 14px; padding: 1rem 1.1rem; min-height: 260px;
}
.hm-chart-head {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;
}
.hm-chart-head h3 {
margin: 0; font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: #94a3b8; font-weight: 600;
display: flex; align-items: center; gap: 0.5rem;
}
.hm-live-dot {
width: 8px; height: 8px; border-radius: 50%; background: #22c55e;
box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite;
}
@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.hm-chart-head small { color: #64748b; font-size: 0.7rem; }
.hm-chart-wrap { position: relative; height: 200px; }
.briefing-loading-overlay {
position: absolute; inset: 0; background: rgba(10, 14, 20, 0.8);
backdrop-filter: blur(4px); display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 1rem; z-index: 20; border-radius: 12px;
}
.briefing-loading-overlay .loading-dots { display: flex; gap: 0.5rem; }
.briefing-loading-overlay .loading-dots span {
width: 12px; height: 12px; border-radius: 50%; background: var(--hm-cyan);
animation: hmEq 0.8s ease-in-out infinite alternate;
}
.briefing-loading-overlay .loading-dots span:nth-child(2) { animation-delay: 0.15s; }
.briefing-loading-overlay .loading-dots span:nth-child(3) { animation-delay: 0.3s; }
.briefing-typewriter { color: #f1f5f9; line-height: 1.75; font-size: 1rem; min-height: 3rem; }
.horizon-card ul { color: #dce6f0; line-height: 1.65; }
.retail-highlight { text-decoration: none; color: inherit; display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--hm-border); }
.retail-highlight:hover { background: rgba(0,229,255,0.06); }
.hub-kpi-row .kpi-card { animation: none !important; }
/* Agents page */
.agents-neo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.agent-soul-card {
background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px;
padding: 1rem; cursor: pointer; transition: all 0.2s;
}
.agent-soul-card:hover { border-color: rgba(0,229,255,0.4); box-shadow: 0 0 20px rgba(0,229,255,0.08); }
.agent-soul-card.selected { border-color: var(--hm-gold); }
.agent-soul-editor {
background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px;
padding: 1.25rem; margin-top: 1rem;
}
.agent-soul-editor textarea {
width: 100%; min-height: 280px; font-family: var(--font-mono); font-size: 0.85rem;
background: #060a10; color: #e2e8f0; border: 1px solid var(--hm-border); border-radius: 8px; padding: 0.75rem;
}
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.perm-card {
background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 10px;
padding: 0.85rem; display: flex; justify-content: space-between; align-items: center;
}
.perm-card.granted { border-color: rgba(34,197,94,0.4); }
/* CEO market dashboard */
.hm-ceo-market-row {
display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem;
}
@media (max-width: 1000px) { .hm-ceo-market-row { grid-template-columns: 1fr; } }
.hm-stock-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.65rem;
}
.hm-stock-card {
background: rgba(0,0,0,0.25); border: 1px solid var(--hm-border); border-radius: 12px;
padding: 0.65rem 0.75rem; transition: border-color 0.2s, box-shadow 0.2s;
}
.hm-stock-card:hover { border-color: rgba(0,229,255,0.35); box-shadow: 0 0 16px rgba(0,229,255,0.08); }
.hm-stock-up { border-color: rgba(34,197,94,0.25); }
.hm-stock-down { border-color: rgba(251,113,133,0.25); }
.hm-stock-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.35rem; }
.hm-stock-head strong { display: block; font-size: 0.85rem; color: #f1f5f9; }
.hm-stock-head small { font-size: 0.65rem; color: #94a3b8; }
.hm-stock-pct { font-size: 0.72rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 6px; }
.hm-stock-pct.up { color: #4ade80; background: rgba(74,222,128,0.12); }
.hm-stock-pct.down { color: #fb7185; background: rgba(251,113,133,0.12); }
.hm-stock-price { font-size: 1.15rem; font-weight: 700; color: #f8fafc; margin: 0.35rem 0 0.15rem; }
.hm-stock-price small { font-size: 0.65rem; color: #94a3b8; font-weight: 400; }
.hm-stock-chain { font-size: 0.65rem; color: #64748b; margin-bottom: 0.35rem; }
.hm-spark { width: 100%; height: 36px; display: block; opacity: 0.85; }
.hm-eq-down span { background: linear-gradient(to top, rgba(251,113,133,0.3), #fb7185); box-shadow: 0 0 6px rgba(251,113,133,0.35); }
.hm-highlight-scroll { max-height: 220px; overflow-y: auto; }
.hm-highlight-item {
padding: 0.55rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
}
.hm-highlight-item strong { color: #e2e8f0; font-size: 0.85rem; text-decoration: none; }
.hm-highlight-item a { color: inherit; text-decoration: none; }
.hm-highlight-item a:hover strong { color: var(--hm-cyan); }
.hm-highlight-item small { display: block; color: #64748b; font-size: 0.7rem; margin-top: 0.15rem; }
.hm-cat-badge {
display: inline-block; font-size: 0.55rem; letter-spacing: 0.1em; font-weight: 700;
padding: 0.1rem 0.35rem; border-radius: 4px; margin-bottom: 0.25rem;
background: rgba(255,159,67,0.15); color: #ff9f43;
}
.hm-cat-badge.hm-cat-cbs { background: rgba(56,189,248,0.15); color: #38bdf8; }
.hm-cat-badge.hm-cat-regelgeving { background: rgba(168,85,247,0.15); color: #a855f7; }
.hm-neo-kpi-row { grid-template-columns: repeat(6, 1fr); }
@media (max-width: 1200px) { .hm-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
/* Agent animated avatars */
.agent-avatar-wrap {
position: relative; width: 64px; height: 64px; flex-shrink: 0;
}
.agent-avatar {
width: 64px; height: 64px; border-radius: 50%; position: relative;
display: grid; place-items: center; font-size: 1.5rem;
box-shadow: 0 0 20px color-mix(in srgb, var(--av-color, #00e5ff) 40%, transparent);
animation: agentBob 3s ease-in-out infinite;
}
.agent-avatar::before {
content: ''; position: absolute; inset: -3px; border-radius: 50%;
border: 2px solid var(--av-color, #00e5ff); opacity: 0.5;
animation: agentPulseRing 2s ease-out infinite;
}
.agent-face {
width: 48px; height: 48px; border-radius: 50%; background: var(--av-color, #00e5ff);
position: relative; overflow: hidden;
}
.agent-face .eye {
position: absolute; top: 16px; width: 7px; height: 9px; background: #0a0e14; border-radius: 50%;
animation: agentBlink 4s infinite;
}
.agent-face .eye.left { left: 12px; }
.agent-face .eye.right { right: 12px; }
.agent-face .mouth {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
width: 14px; height: 7px; border-bottom: 3px solid #0a0e14; border-radius: 0 0 14px 14px;
}
.agent-face.mood-busy .mouth { width: 10px; height: 3px; border: none; background: #0a0e14; border-radius: 2px; bottom: 12px; }
.agent-face.mood-creative .mouth { border-bottom-color: #0a0e14; width: 16px; height: 8px; }
/* Hair, glasses, gender styles */
.agent-face .hair {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 90%; height: 22px; border-radius: 50% 50% 0 0;
background: #1a1410; z-index: 1;
}
.agent-face.style-female-long .hair {
width: 110%; height: 28px; border-radius: 40% 40% 0 0;
background: linear-gradient(180deg, #2d1810, #1a1008);
}
.agent-face.style-female-long::after {
content: ''; position: absolute; top: 18px; left: -4px; width: 12px; height: 22px;
background: #2d1810; border-radius: 0 0 50% 50%; z-index: 0;
}
.agent-face.style-female-bob .hair {
width: 100%; height: 20px; background: #3d2817; border-radius: 30% 30% 0 0;
}
.agent-face.style-male-short .hair {
width: 85%; height: 14px; background: #0f0f0f; border-radius: 40% 40% 0 0;
}
.agent-face.style-male-curl .hair {
width: 95%; height: 18px; background: #2a1810;
border-radius: 50% 50% 0 0; box-shadow: -6px 2px 0 #2a1810, 6px 2px 0 #2a1810;
}
.agent-face .glasses {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
width: 34px; height: 10px; border: 2px solid #0a0e14; border-radius: 4px;
z-index: 3; box-shadow: -8px 0 0 -2px #0a0e14, 8px 0 0 -2px #0a0e14;
}
.agent-face.style-female-long .eye { top: 18px; }
.agent-face.style-male-glasses .eye { top: 15px; }
.hm-exec-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.65rem;
}
.hm-exec-item {
display: flex; gap: 0.65rem; align-items: center; padding: 0.75rem;
border-radius: 10px; background: rgba(0,0,0,0.25); border: 1px solid rgba(148,163,184,0.1);
text-decoration: none; color: inherit; transition: border-color 0.2s, transform 0.2s;
}
.hm-exec-item:hover { border-color: rgba(0,229,255,0.35); transform: translateY(-2px); }
.hm-exec-icon { font-size: 1.4rem; }
.hm-exec-item strong { display: block; font-size: 0.78rem; color: #94a3b8; font-weight: 600; }
.hm-exec-val { font-size: 1.1rem; color: #e2e8f0; font-weight: 700; }
.hm-exec-list { margin: 0; padding-left: 1.2rem; color: #cbd5e1; font-size: 0.85rem; }
.hm-exec-list a { color: #38bdf8; }
.agent-status-dot {
position: absolute; bottom: 2px; right: 2px; width: 12px; height: 12px;
border-radius: 50%; background: #22c55e; border: 2px solid var(--hm-panel);
box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite;
}
@keyframes agentBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes agentPulseRing { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(1.15); opacity: 0; } }
@keyframes agentBlink { 0%,96%,100% { transform: scaleY(1); } 98% { transform: scaleY(0.1); } }
.agent-soul-card .agent-role-desc {
font-size: 0.82rem; color: #cbd5e1; line-height: 1.5; margin: 0.65rem 0;
min-height: 2.5rem;
}
.agent-soul-card .agent-meta-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.72rem; color: #94a3b8;
}
.agent-soul-card .agent-tag {
display: inline-block; padding: 0.15rem 0.45rem; border-radius: 999px;
background: rgba(0,229,255,0.1); color: var(--hm-cyan); font-size: 0.65rem;
}
.agent-soul-editor .agent-editor-header {
display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem;
}
.agent-soul-editor .agent-editor-header h2 { margin: 0; }
+218
View File
@@ -0,0 +1,218 @@
/* Hermes Neo Command Center — network monitor aesthetic */
.hm-neo { --hm-cyan: #00e5ff; --hm-mag: #ff2d95; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); }
.hm-neo-header h1 { font-size: 1.65rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.hm-neo-header .hm-sub { color: var(--hm-cyan); font-size: 0.85rem; margin: 0.35rem 0 0; opacity: 0.85; }
/* ── KPI cards (neo style) ── */
.hm-neo-kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 1.25rem 0;
}
@media (max-width: 1100px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .hm-neo-kpi-row { grid-template-columns: 1fr; } }
.hm-neo-kpi {
position: relative;
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1rem 1.1rem 0.75rem;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.hm-neo-kpi:hover { border-color: rgba(0, 229, 255, 0.35); box-shadow: 0 0 24px rgba(0, 229, 255, 0.08); }
.hm-neo-kpi.highlight { border-color: var(--hm-gold); box-shadow: 0 0 20px rgba(255, 215, 0, 0.12); }
.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.hm-neo-ring {
width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0);
display: grid; place-items: center;
box-shadow: 0 0 12px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 40%, transparent);
}
.hm-neo-ring-inner {
width: 32px; height: 32px; border-radius: 50%;
background: var(--hm-panel); display: grid; place-items: center;
font-size: 1rem;
}
.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; }
.hm-neo-kpi-value { font-size: 2rem; font-weight: 700; line-height: 1.1; color: #f1f5f9; font-variant-numeric: tabular-nums; }
.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; margin-top: 0.15rem; }
/* Animated equalizer bars */
.hm-eq {
display: flex; align-items: flex-end; justify-content: center; gap: 3px;
height: 28px; margin-top: 0.65rem; padding-top: 0.35rem;
border-top: 1px solid rgba(255,255,255,0.04);
}
.hm-eq span {
width: 5px; border-radius: 2px;
background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan));
animation: hmEq 0.55s ease-in-out infinite alternate;
box-shadow: 0 0 6px rgba(0, 229, 255, 0.35);
}
.hm-eq span:nth-child(1) { animation-delay: 0s; }
.hm-eq span:nth-child(2) { animation-delay: 0.08s; }
.hm-eq span:nth-child(3) { animation-delay: 0.16s; }
.hm-eq span:nth-child(4) { animation-delay: 0.24s; }
.hm-eq span:nth-child(5) { animation-delay: 0.32s; }
.hm-eq span:nth-child(6) { animation-delay: 0.12s; }
.hm-eq span:nth-child(7) { animation-delay: 0.2s; }
.hm-eq span:nth-child(8) { animation-delay: 0.28s; }
@keyframes hmEq {
from { height: 18%; opacity: 0.45; }
to { height: 92%; opacity: 1; }
}
/* ── Chart panels ── */
.hm-neo-charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } }
.hm-chart-panel {
background: var(--hm-panel);
border: 1px solid var(--hm-border);
border-radius: 14px;
padding: 1rem 1.1rem;
min-height: 280px;
}
.hm-chart-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.75rem;
}
.hm-chart-head h3 {
margin: 0; font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: #94a3b8; font-weight: 600;
}
.hm-chart-head .hm-live-dot {
width: 8px; height: 8px; border-radius: 50%; background: #22c55e;
box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite;
}
@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.hm-chart-head small { color: #475569; font-size: 0.7rem; }
.hm-chart-wrap { position: relative; height: 220px; }
/* ── Tabs ── */
.hermes-tabs {
display: flex; flex-wrap: wrap; gap: 0.55rem;
margin: 0 0 1rem; padding: 0.45rem;
background: rgba(8, 12, 18, 0.8);
border: 1px solid var(--hm-border);
border-radius: 12px;
}
.hermes-tab {
flex: 1 1 auto; min-width: 120px;
padding: 0.8rem 1.1rem; font-size: 0.88rem; font-weight: 600;
border-radius: 10px; cursor: pointer;
border: 1px solid rgba(100, 116, 139, 0.25);
background: rgba(15, 23, 42, 0.7); color: #94a3b8;
transition: all 0.15s;
}
.hermes-tab:hover { border-color: rgba(0, 229, 255, 0.4); color: #e2e8f0; }
.hermes-tab.active {
background: linear-gradient(135deg, rgba(0, 229, 255, 0.12), rgba(255, 215, 0, 0.08));
border-color: var(--hm-gold); color: #fef9c3;
box-shadow: 0 0 18px rgba(255, 215, 0, 0.15);
}
/* ── Team roster (no duplicate names) ── */
.hm-team { margin-bottom: 1rem; }
.hm-team h3 { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #64748b; margin: 0 0 0.6rem; }
.hm-team-card {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.65rem 0.85rem; margin-bottom: 0.45rem;
background: rgba(13, 18, 25, 0.9);
border: 1px solid var(--hm-border);
border-radius: 10px;
}
.hm-team-card.online { border-left: 3px solid #22c55e; }
.hm-team-card .hm-role-badge {
min-width: 52px; text-align: center;
font-size: 0.65rem; font-weight: 800; letter-spacing: 0.08em;
padding: 0.25rem 0.4rem; border-radius: 6px;
}
.hm-team-card .hm-role-ceo { background: rgba(255, 215, 0, 0.15); color: var(--hm-gold); border: 1px solid rgba(255,215,0,0.3); }
.hm-team-card .hm-role-cto { background: rgba(0, 229, 255, 0.12); color: var(--hm-cyan); border: 1px solid rgba(0,229,255,0.25); }
.hm-team-card .hm-person { flex: 1; }
.hm-team-card .hm-person strong { display: block; font-size: 0.95rem; color: #f1f5f9; }
.hm-team-card .hm-person small { color: #64748b; font-size: 0.72rem; }
.hm-team-card .hm-status-dot { width: 10px; height: 10px; border-radius: 50%; }
.hm-team-card .hm-status-dot.on { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.hm-team-card .hm-status-dot.off { background: #475569; }
.hermes-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; }
@media (max-width: 900px) { .hermes-grid { grid-template-columns: 1fr; } }
.hermes-sidebar { max-height: 72vh; overflow-y: auto; }
.hermes-conv-btn {
display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left;
margin: 0.3rem 0; padding: 0.5rem 0.65rem;
background: rgba(13, 18, 25, 0.8); border: 1px solid var(--hm-border);
border-radius: 8px; color: #e2e8f0; cursor: pointer;
}
.hermes-conv-btn.active { border-color: var(--hm-cyan); box-shadow: 0 0 0 1px var(--hm-cyan); }
.hermes-conv-btn .conv-role { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.35rem; border-radius: 4px; }
.hermes-feed { max-height: 72vh; display: flex; flex-direction: column; }
.hermes-messages { overflow-y: auto; flex: 1; padding: 0.5rem; }
.hermes-msg {
margin-bottom: 0.65rem; padding: 0.6rem 0.8rem; border-radius: 8px;
border-left: 3px solid rgba(100,116,139,0.4);
background: rgba(13, 18, 25, 0.6);
}
.hermes-msg.msg-in { border-left-color: var(--hm-cyan); background: rgba(0, 229, 255, 0.04); }
.hermes-msg.msg-out { border-left-color: var(--hm-purple); background: rgba(168, 85, 247, 0.05); }
.hermes-msg-meta { font-size: 0.72rem; color: #64748b; display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.hermes-msg-body { white-space: pre-wrap; word-break: break-word; font-size: 0.88rem; }
/* PA live */
.hermes-pa-header {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
margin-bottom: 1rem; padding: 0.75rem 1rem;
background: rgba(0, 229, 255, 0.05); border: 1px solid var(--hm-border); border-radius: 10px;
}
.hermes-pa-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
@media (max-width: 900px) { .hermes-pa-grid { grid-template-columns: 1fr; } }
.hermes-pa-slot { border: 1px solid var(--hm-border); border-radius: 10px; overflow: hidden; background: var(--hm-panel); }
.hermes-pa-slot.status-loading { border-color: var(--hm-cyan); }
.hermes-pa-slot.status-completed { border-color: #22c55e; }
.hermes-pa-slot.status-failed { border-color: #ef4444; }
.hermes-pa-slot-head { display: flex; justify-content: space-between; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.3); }
.hermes-pa-slot-body { height: 200px; background: #060a10; display: grid; place-items: center; }
.hermes-pa-slot-body img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.hermes-pa-placeholder { color: #64748b; font-size: 0.82rem; text-align: center; padding: 1rem; }
.hermes-pa-slot-foot { padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-badge { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 4px; }
.status-badge.loading { background: rgba(0,229,255,0.15); color: var(--hm-cyan); }
.status-badge.completed { background: rgba(34,197,94,0.15); color: #22c55e; }
.status-badge.failed { background: rgba(239,68,68,0.12); color: #ef4444; }
.status-badge.waiting, .status-badge.idle { background: rgba(100,116,139,0.15); color: #94a3b8; }
.status-badge.running, .status-badge.comparing { background: rgba(255,159,67,0.15); color: var(--hm-orange); }
.hermes-graph-canvas { height: 480px; border: 1px solid var(--hm-border); border-radius: 10px; background: #060a10; }
.hermes-search-bar { display: flex; gap: 0.5rem; margin: 1rem 0; }
.hermes-search-bar .form-input { flex: 1; }
.hermes-search-hit { padding: 0.65rem; border-bottom: 1px solid var(--hm-border); }
.hermes-control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.hermes-info-list { list-style: none; padding: 0; }
.hermes-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.tag { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.78rem; }
.tag-ok { background: rgba(34,197,94,0.15); color: #22c55e; }
.tag-block { background: rgba(239,68,68,0.12); color: #ef4444; }
.tag-pa { background: rgba(168,85,247,0.15); color: var(--hm-purple); }
.hermes-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
.hermes-events { max-height: 360px; overflow-y: auto; }
.hermes-event { padding: 0.45rem 0; border-bottom: 1px solid var(--hm-border); }
.panel-header { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; margin-bottom: 0.65rem; }
.hermes-select { max-width: 200px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.dot-in { background: var(--hm-cyan); }
.dot-out { background: var(--hm-purple); }
.dot-edge { background: var(--hm-orange); }
+146
View File
@@ -0,0 +1,146 @@
/**
* Foodlinkk Command Center — mobile (Android + iOS)
*/
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
min-width: 44px;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 10px;
background: rgba(15, 23, 42, 0.9);
color: #e2e8f0;
font-size: 1.35rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 998;
backdrop-filter: blur(2px);
}
@media (max-width: 1024px) {
.btn, .btn-sm, .nav-pill, .vtab-btn, .side-nav-btn {
min-height: 44px;
}
.form-input, select, textarea, input {
font-size: 16px !important;
min-height: 44px;
}
.main-topnav {
padding: 0.75rem;
padding-left: max(0.75rem, var(--safe-left));
padding-right: max(0.75rem, var(--safe-right));
}
.page-header { flex-direction: column; align-items: stretch !important; gap: 0.75rem; }
.page-header h1 { font-size: 1.35rem !important; }
.retail-actions, .herman-briefing-actions {
display: flex; flex-wrap: wrap; gap: 0.5rem;
}
.retail-actions .btn, .herman-briefing-actions .btn {
flex: 1 1 calc(50% - 0.25rem); min-width: calc(50% - 0.25rem);
}
.data-table-wrap, .retail-table-wrap, .panel {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.data-table { min-width: 520px; }
.retail-table { min-width: 480px; }
.kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis {
grid-template-columns: repeat(2, 1fr) !important;
}
.hm-exec-grid { grid-template-columns: 1fr !important; }
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
.vtabs-layout { grid-template-columns: 1fr !important; }
.vtabs-nav {
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
position: relative !important;
}
.vtabs-nav::before { display: none; }
.vtab-btn { flex: 0 0 auto; white-space: nowrap; }
.retail-workspace, .retail-workspace.with-filters {
grid-template-columns: 1fr !important;
}
.retail-sidebar .side-nav {
display: flex; flex-direction: row; overflow-x: auto;
-webkit-overflow-scrolling: touch; gap: 0.35rem;
}
.retail-sidebar .side-nav-btn { flex: 0 0 auto; white-space: nowrap; }
.retail-map { min-height: 280px !important; height: 45vh !important; }
.analytics-shell { grid-template-columns: 1fr !important; }
.hub-grid, .hm-dash-feed, .grid-2, .briefing-summary-grid, .hm-dash-two-col {
grid-template-columns: 1fr !important;
}
.agents-neo-grid, .reports-grid, .concept-grid { grid-template-columns: 1fr !important; }
[style*="grid-template-columns:1fr 1fr"],
[style*="grid-template-columns: 1fr 1fr"],
.regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; }
.regs-columns { flex-direction: column !important; display: flex !important; }
.pwa-install-bar { display: flex; }
}
@media (max-width: 768px) {
.mobile-menu-btn { display: inline-flex; }
.mobile-nav-overlay { display: block; }
body:not(.nav-open) .mobile-nav-overlay { display: none; }
.app-shell {
grid-template-columns: 1fr !important;
display: block !important;
}
.app-sidebar {
position: fixed !important;
top: 0; left: 0; bottom: 0;
width: min(280px, 88vw);
height: 100dvh !important;
z-index: 999;
transform: translateX(-105%);
transition: transform 0.3s ease;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
padding-top: max(1rem, var(--safe-top));
flex-direction: column !important;
flex-wrap: nowrap !important;
}
body.nav-open .app-sidebar { transform: translateX(0); }
body.nav-open { overflow: hidden; }
.app-topbar {
position: sticky; top: 0; z-index: 100;
padding-top: max(0.5rem, var(--safe-top));
}
.app-topbar .page-tip { display: none; }
.app-clock { font-size: 0.72rem; flex: 1; text-align: right; }
.retail-actions .btn, .herman-briefing-actions .btn {
min-width: 100%; flex: 1 1 100%;
}
}
.pwa-install-bar {
display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, var(--safe-bottom));
background: rgba(8, 12, 18, 0.95);
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; } }
+130
View File
@@ -0,0 +1,130 @@
.ops-shell {
display: grid;
gap: 1rem;
}
.ops-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.ops-header h1 {
margin: 0;
}
.ops-subtitle {
color: #93a4b8;
margin: 0.25rem 0 0;
}
.ops-kpis {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.ops-kpi {
border: 1px solid rgba(120, 170, 255, 0.25);
border-radius: 12px;
padding: 0.8rem;
background: linear-gradient(150deg, rgba(15, 23, 42, 0.95), rgba(17, 30, 53, 0.7));
box-shadow: inset 0 0 20px rgba(36, 99, 235, 0.08), 0 0 20px rgba(30, 64, 175, 0.12);
}
.ops-kpi .label {
color: #8ba3c4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.ops-kpi .value {
font-size: 1.3rem;
font-weight: 700;
margin-top: 0.2rem;
}
.topology-card {
position: relative;
border-radius: 14px;
border: 1px solid rgba(56, 189, 248, 0.3);
background: radial-gradient(circle at top, rgba(21, 43, 77, 0.35), rgba(8, 15, 29, 0.92));
overflow: hidden;
}
.topology-canvas {
width: 100%;
min-height: 520px;
}
.topology-lines line {
stroke: rgba(59, 130, 246, 0.42);
stroke-width: 2;
}
.pulse-line {
stroke: rgba(56, 189, 248, 0.95);
stroke-width: 2.6;
stroke-dasharray: 10 14;
animation: ops-line-pulse 1.8s linear infinite;
filter: drop-shadow(0 0 6px rgba(56, 189, 248, 0.85));
}
.ops-node circle {
fill: #0f172a;
stroke-width: 2.2;
}
.ops-node text {
fill: #d9e8ff;
font-size: 12px;
font-weight: 600;
}
.ops-node .sub {
fill: #8ea7ca;
font-size: 11px;
font-weight: 500;
}
.node-proxmox circle {
stroke: #f59e0b;
filter: drop-shadow(0 0 11px rgba(245, 158, 11, 0.6));
}
.node-vm circle {
stroke: #38bdf8;
filter: drop-shadow(0 0 9px rgba(56, 189, 248, 0.5));
}
.node-service circle {
stroke: #22c55e;
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.45));
}
.status-offline circle {
stroke: #ef4444;
filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.6));
}
.status-degraded circle,
.status-unknown circle {
stroke: #f59e0b;
}
.ops-meta {
color: #8ba3c4;
font-size: 0.85rem;
}
@keyframes ops-line-pulse {
from {
stroke-dashoffset: 26;
}
to {
stroke-dashoffset: 0;
}
}
+867
View File
@@ -0,0 +1,867 @@
@import url('tokens.css');
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg-root);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
}
a {
color: var(--accent-cyan);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
padding: 1.25rem 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.brand {
padding: 0 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 1rem;
}
.brand-title {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 0.25rem;
}
.brand-name {
font-size: 1.05rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.nav {
list-style: none;
margin: 0;
padding: 0 0.75rem;
flex: 1;
}
.nav li {
margin-bottom: 0.25rem;
}
.nav a {
display: block;
padding: 0.55rem 0.75rem;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
}
.nav a:hover,
.nav a.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav a.active {
border-left: 2px solid var(--accent-cyan);
}
.main {
margin-left: var(--sidebar-width);
flex: 1;
padding: 1.5rem 2rem 2rem;
max-width: 1400px;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 600;
}
.page-header .subtitle {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
}
.panel {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-panel);
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.panel h2 {
margin: 0 0 1rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 600;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.kpi-card {
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 1rem 1.15rem;
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--kpi-accent, var(--accent-cyan));
}
.kpi-card.purple::before { background: var(--accent-purple); }
.kpi-card.amber::before { background: var(--accent-amber); }
.kpi-card.green::before { background: var(--accent-green); }
.kpi-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.kpi-value {
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 960px) {
.grid-2 {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
width: 100%;
}
.main {
margin-left: 0;
}
.layout {
flex-direction: column;
}
}
.herman-briefing {
border-left: 3px solid var(--accent-cyan);
background: linear-gradient(135deg, var(--accent-cyan-dim), transparent);
}
.herman-briefing .briefing-meta {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.herman-briefing .briefing-body {
white-space: pre-wrap;
color: var(--text-secondary);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.45rem 0.85rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--bg-hover);
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.btn:hover {
background: var(--bg-elevated);
text-decoration: none;
}
.btn-primary {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.btn-approve {
border-color: var(--accent-green);
color: var(--accent-green);
background: var(--accent-green-dim);
}
.btn-reject {
border-color: var(--accent-red);
color: var(--accent-red);
background: var(--accent-red-dim);
}
.agent-feed {
list-style: none;
margin: 0;
padding: 0;
max-height: 420px;
overflow-y: auto;
}
.agent-feed li {
display: flex;
gap: 0.75rem;
padding: 0.65rem 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 0.8125rem;
}
.agent-feed li:last-child {
border-bottom: none;
}
.feed-time {
flex-shrink: 0;
width: 4.5rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.7rem;
}
.feed-body {
flex: 1;
min-width: 0;
}
.feed-message {
color: var(--text-secondary);
}
.badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-agent-herman,
.badge-agent-cyan {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
.badge-agent-sales,
.badge-agent-purple {
background: var(--accent-purple-dim);
color: var(--accent-purple);
}
.badge-agent-marketing,
.badge-agent-amber {
background: var(--accent-amber-dim);
color: var(--accent-amber);
}
.badge-agent-ops,
.badge-agent-green {
background: var(--accent-green-dim);
color: var(--accent-green);
}
.badge-status-needs_approval {
background: var(--accent-amber-dim);
color: var(--accent-amber);
}
.badge-status-approved,
.badge-status-completed {
background: var(--accent-green-dim);
color: var(--accent-green);
}
.badge-status-rejected {
background: var(--accent-red-dim);
color: var(--accent-red);
}
.approval-queue .approval-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-subtle);
}
.approval-queue .approval-item:last-child {
border-bottom: none;
}
.approval-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.approval-actions form {
margin: 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.data-table th,
.data-table td {
text-align: left;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--border-subtle);
}
.data-table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.agent-card {
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 1rem;
}
.agent-card.cyan { border-top: 2px solid var(--accent-cyan); }
.agent-card.purple { border-top: 2px solid var(--accent-purple); }
.agent-card.amber { border-top: 2px solid var(--accent-amber); }
.agent-card.green { border-top: 2px solid var(--accent-green); }
.agent-card h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.agent-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.8rem;
}
.muted {
color: var(--text-muted);
}
.empty-state {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0;
}
/* Fase 2-4: kanban, charts, voice */
.kanban-board { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 1rem; margin-bottom: 1.25rem; }
.kanban-column { min-width: 220px; flex: 0 0 220px; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 0.75rem; }
.kanban-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin: 0 0 0.75rem; }
.kanban-card { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.65rem; margin-bottom: 0.5rem; font-size: 0.8125rem; }
.chart-placeholder { min-height: 160px; }
.chart-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.45rem; font-size: 0.75rem; }
.chart-bar .bar { height: 8px; background: var(--accent-cyan); border-radius: 4px; min-width: 4px; }
.chart-bar .bar-purple { background: var(--accent-purple); }
.voice-widget { text-align: center; }
.voice-mic { margin: 1rem auto; min-width: 160px; }
.voice-wave { display: flex; justify-content: center; gap: 0.35rem; height: 32px; align-items: flex-end; }
.voice-wave span { width: 4px; height: 12px; background: var(--accent-cyan-dim); border-radius: 2px; animation: voice-pulse 1.2s ease-in-out infinite; }
.voice-wave span:nth-child(2) { animation-delay: 0.2s; }
.voice-wave span:nth-child(3) { animation-delay: 0.4s; }
@keyframes voice-pulse { 0%, 100% { height: 8px; opacity: 0.5; } 50% { height: 24px; opacity: 1; } }
.herman-form textarea { width: 100%; background: var(--bg-root); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); padding: 0.75rem; font-family: inherit; margin-bottom: 0.75rem; }
/* Command Center interactive UI */
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; align-items: center; justify-content: center; padding: 1rem; }
.modal.open { display: flex; }
.modal-card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 1.5rem; max-width: 480px; width: 100%; box-shadow: var(--shadow-lg); }
.drawer { position: fixed; top: 0; right: 0; width: min(420px, 90vw); height: 100vh; background: var(--bg-surface); border-left: 1px solid var(--border-subtle); transform: translateX(100%); transition: transform .25s ease; z-index: 90; padding: 1.5rem; overflow: auto; }
.drawer.open { transform: translateX(0); }
.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; }
.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.btn-sm { padding: .25rem .55rem; font-size: 12px; }
.clickable-row { cursor: pointer; transition: background .15s; }
.clickable-row:hover { background: var(--bg-hover); }
.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; }
.clickable-kpi:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.25); text-decoration: none; }
.tab-bar { display: flex; gap: .35rem; margin-bottom: 1rem; flex-wrap: wrap; }
.tab-bar button { background: var(--bg-surface); border: 1px solid var(--border-subtle); color: var(--text-secondary); padding: .45rem .9rem; border-radius: var(--radius-md); cursor: pointer; }
.tab-bar button.active { border-color: var(--accent-cyan); color: var(--text-primary); background: var(--bg-hover); }
.toast-container { position: fixed; bottom: 1rem; right: 1rem; z-index: 200; display: flex; flex-direction: column; gap: .5rem; }
.toast { padding: .75rem 1rem; border-radius: var(--radius-md); background: var(--bg-surface); border: 1px solid var(--border-subtle); animation: toastIn .2s ease; }
.toast-success { border-color: var(--accent-green); }
.toast-error { border-color: var(--accent-red, #f44); }
.toast-out { opacity: 0; transition: opacity .3s; }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.form-input { width: 100%; padding: .55rem .75rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); background: var(--bg-root); color: var(--text-primary); }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-subtle); text-align: left; }
.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; }
.kanban-col { background: var(--bg-surface); border-radius: var(--radius-lg); padding: .75rem; border: 1px solid var(--border-subtle); min-height: 200px; }
.kanban-card { background: var(--bg-root); border-radius: var(--radius-md); padding: .65rem; margin-bottom: .5rem; border: 1px solid var(--border-subtle); }
.chat-layout { display: flex; flex-direction: column; height: calc(100vh - 4rem); }
.chat-messages { flex: 1; overflow: auto; padding: 1rem; background: var(--bg-surface); border-radius: var(--radius-lg); margin-bottom: 1rem; }
.chat-msg { margin-bottom: .75rem; padding: .65rem; border-radius: var(--radius-md); }
.chat-msg.user { background: rgba(0,200,255,.08); }
.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); }
.chat-input { display: flex; gap: .5rem; }
.page-tip { color: var(--text-muted); font-size: 13px; }
.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; }
.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; }
body.drawer-open { overflow: hidden; }
/* Herman Daily Briefing — visual dashboard */
.herman-briefing { border-left: 3px solid var(--accent-cyan); }
.herman-briefing-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
.herman-briefing-header h2 { margin: 0; }
.briefing-meta { font-size: 0.75rem; color: var(--text-muted); }
.briefing-summary-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
.briefing-card {
background: var(--surface-elevated, rgba(15, 23, 42, 0.6));
border: 1px solid var(--border-subtle, rgba(148, 163, 184, 0.15));
border-radius: 10px;
padding: 1rem 1.15rem;
}
.briefing-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent-gold, #e8a838); }
.briefing-card.summary { border-color: rgba(56, 189, 248, 0.25); }
.briefing-card.actions { border-color: rgba(232, 168, 56, 0.25); }
.briefing-card.actions ul { margin: 0; padding-left: 1.1rem; color: var(--text-secondary); line-height: 1.55; }
.briefing-card.actions li { margin-bottom: 0.35rem; }
#briefing-summary-text { color: var(--text-primary, #e2e8f0); line-height: 1.6; font-size: 0.95rem; }
.briefing-kpi-row { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-bottom: 1.25rem; }
.briefing-stat {
flex: 1; min-width: 100px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
padding: 0.65rem 0.85rem;
text-align: center;
}
.briefing-stat.green { border-color: rgba(62, 207, 142, 0.35); }
.briefing-stat.purple { border-color: rgba(167, 139, 250, 0.35); }
.briefing-stat.cyan { border-color: rgba(56, 189, 248, 0.35); }
.briefing-stat.amber { border-color: rgba(232, 168, 56, 0.35); }
.briefing-stat-label { display: block; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.briefing-stat-value { display: block; font-size: 1.35rem; font-weight: 600; color: var(--text-primary); margin-top: 0.15rem; }
.briefing-charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
@media (max-width: 900px) { .briefing-charts-grid { grid-template-columns: 1fr; } }
.briefing-chart-panel {
background: rgba(15, 23, 42, 0.45);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 10px;
padding: 0.75rem;
min-height: 220px;
}
.briefing-details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
@media (max-width: 900px) { .briefing-details-grid { grid-template-columns: 1fr; } }
.briefing-pending-item {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
color: var(--text-secondary);
font-size: 0.9rem;
}
.briefing-toggle { margin-top: 0.5rem; }
.briefing-full-report {
display: none;
margin-top: 0.75rem;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
font-size: 0.8rem;
color: var(--text-muted);
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 1rem;
border: 1px solid rgba(148, 163, 184, 0.1);
}
.briefing-full-report.open { display: block; }
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; }
/* Settings page */
.settings-header { margin-bottom: 0.5rem; }
.settings-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
padding-bottom: 0;
}
.settings-tab {
display: inline-block;
padding: 0.65rem 1.1rem;
color: var(--text-muted, #94a3b8);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 0.9rem;
transition: color 0.15s, border-color 0.15s;
}
.settings-tab:hover { color: var(--text-primary, #e2e8f0); }
.settings-tab.active {
color: var(--accent-gold, #e8a838);
border-bottom-color: var(--accent-gold, #e8a838);
}
.settings-panel { border-left: 3px solid var(--accent-gold, #e8a838); }
.settings-panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.settings-active-banner {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.65rem 0.85rem;
background: rgba(62, 207, 142, 0.08);
border: 1px solid rgba(62, 207, 142, 0.25);
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.email-account-list { display: flex; flex-direction: column; gap: 0.75rem; }
.email-account-card {
background: rgba(15, 23, 42, 0.45);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.email-account-card.is-active { border-color: rgba(62, 207, 142, 0.35); }
.email-account-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.email-account-card-head strong { display: block; }
.email-account-card-head .muted { font-size: 0.85rem; color: var(--text-muted); }
.email-account-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.settings-modal { max-width: 520px; width: 100%; }
.settings-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0;
}
@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } }
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; }
.form-label .form-input { margin-top: 0.25rem; }
.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; }
.settings-details {
margin: 0.75rem 0;
padding: 0.75rem;
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 8px;
}
.settings-details summary { cursor: pointer; color: var(--text-secondary); font-size: 0.85rem; }
.sidebar .nav-settings {
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
[x-cloak] { display: none !important; }
/* ── Horizontal top navigation ── */
.topbar {
position: sticky;
top: 0;
z-index: 100;
background: linear-gradient(180deg, #0f172a 0%, #0b1220 100%);
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
padding: 0.65rem 1.25rem 0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
}
.topbar-brand {
display: inline-block;
margin-right: 1.5rem;
margin-bottom: 0.5rem;
vertical-align: middle;
}
.topbar-brand .brand-title { margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; color: var(--accent-gold, #e8a838); text-transform: uppercase; }
.topbar-brand .brand-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary, #e2e8f0); }
.topnav {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.35rem 1.25rem;
padding-bottom: 0;
}
.topnav-herman {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.25rem;
margin-right: 0.5rem;
margin-bottom: -1px;
border-radius: 8px 8px 0 0;
font-weight: 700;
font-size: 0.95rem;
text-decoration: none;
color: #0f172a;
background: linear-gradient(135deg, #e8a838 0%, #f0c060 100%);
border: 1px solid rgba(232, 168, 56, 0.5);
border-bottom: none;
box-shadow: 0 -2px 12px rgba(232, 168, 56, 0.25);
}
.topnav-herman:hover { filter: brightness(1.05); color: #0f172a; }
.topnav-herman.active { background: linear-gradient(135deg, #e8a838 0%, #f5d078 100%); }
.topnav-group {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.15rem;
padding-bottom: 0.45rem;
border-left: 1px solid rgba(148, 163, 184, 0.12);
padding-left: 1rem;
}
.topnav-group:first-of-type { border-left: none; padding-left: 0; }
.topnav-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted, #64748b);
margin-right: 0.35rem;
padding-right: 0.35rem;
}
.topnav-group a {
display: inline-block;
padding: 0.4rem 0.7rem;
border-radius: 6px;
font-size: 0.82rem;
text-decoration: none;
color: var(--text-secondary, #94a3b8);
border: 1px solid transparent;
transition: color 0.15s, background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.topnav-group a:hover {
color: var(--text-primary, #e2e8f0);
background: rgba(148, 163, 184, 0.08);
}
.topnav-group a.active {
color: var(--accent-cyan, #38bdf8);
background: rgba(56, 189, 248, 0.1);
border-color: rgba(56, 189, 248, 0.25);
}
.topnav-group-system { margin-left: auto; border-left: 1px solid rgba(148, 163, 184, 0.12); }
.main-topnav {
padding: 1.25rem 1.5rem 2rem;
min-height: calc(100vh - 80px);
background: var(--bg-root, #070b14);
}
/* Herman hub layout */
.herman-hub-header { margin-bottom: 1rem; }
.herman-hub-header h1 { margin: 0 0 0.25rem; }
.herman-hub-header .subtitle { margin: 0; }
.hub-kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.75rem;
margin-bottom: 1.25rem;
}
@media (max-width: 1100px) { .hub-kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 700px) { .hub-kpi-row { grid-template-columns: repeat(2, 1fr); } }
.hub-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 1000px) { .hub-grid { grid-template-columns: 1fr; } }
.hub-section-tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 1rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
padding-bottom: 0.5rem;
}
.hub-section-tabs button, .hub-section-tabs a {
padding: 0.45rem 0.9rem;
border-radius: 6px 6px 0 0;
font-size: 0.85rem;
background: transparent;
border: 1px solid transparent;
color: var(--text-muted);
text-decoration: none;
cursor: pointer;
}
.hub-section-tabs .active {
color: var(--accent-gold);
border-color: rgba(232, 168, 56, 0.3);
background: rgba(232, 168, 56, 0.08);
}
.hub-feed-item {
padding: 0.55rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
font-size: 0.88rem;
}
.hub-feed-time { color: var(--text-muted); font-size: 0.75rem; margin-right: 0.5rem; }
/* Hide old sidebar layout when topnav is active */
body:has(.topbar) .layout { display: block; }
body:has(.topbar) .sidebar { display: none; }
body:has(.topbar) .main { margin-left: 0; }
@media (max-width: 900px) {
.topnav { gap: 0.25rem 0.75rem; }
.topnav-group { padding-left: 0.5rem; }
.topnav-label { display: none; }
}
/* Compact Herman briefing charts */
.briefing-charts-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 0.75rem;
max-height: 180px;
}
@media (max-width: 1100px) { .briefing-charts-grid { grid-template-columns: repeat(2, 1fr); max-height: none; } }
.briefing-chart-panel {
background: rgba(15, 23, 42, 0.45);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 8px;
padding: 0.35rem 0.5rem;
min-height: 0 !important;
height: 155px;
position: relative;
}
.briefing-chart-panel canvas { max-height: 130px !important; }
.briefing-summary-grid { margin-bottom: 0.75rem; }
.briefing-kpi-row { margin-bottom: 0.75rem; }
.briefing-kpi-row .briefing-stat { padding: 0.45rem 0.6rem; }
.briefing-kpi-row .briefing-stat-value { font-size: 1.1rem; }
.browser-instruct-box {
background: rgba(56, 189, 248, 0.06);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
}
.browser-instruct-box textarea { min-height: 70px; }
.browser-steps-log {
font-size: 0.8rem; color: var(--text-muted);
margin-top: 0.5rem; max-height: 100px; overflow: auto;
}
.browser-action-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
.novnc-fallback {
padding: 1rem; text-align: center; background: #111;
border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
}
.novnc-fallback a { color: var(--accent-cyan); }
+335
View File
@@ -0,0 +1,335 @@
/* Telegram-inspired pulse & live feed theme — global readability */
:root {
--pulse-blue: #38bdf8;
--pulse-green: #4ade80;
--pulse-purple: #c084fc;
--pulse-orange: #fb923c;
--pulse-glow: rgba(56, 189, 248, 0.5);
}
/* ——— Leesbaarheid ——— */
body, .panel, .main-topnav { color: var(--text-body); }
h1, h2, h3, h4, strong, .kpi-value, .briefing-stat-value { color: var(--text-primary) !important; }
.subtitle, .page-tip, .page-header .subtitle { color: var(--text-secondary) !important; font-size: 0.95rem !important; }
.muted, .empty-state, small { color: var(--text-muted) !important; }
.panel p, .feed-card p, .note-bubble p, .hub-feed-item, .data-table td { color: var(--text-body); }
.data-table th { color: var(--text-secondary); }
.feed-card .source { color: var(--pulse-blue) !important; font-weight: 600; }
a, .feed-card a, .ticker-link { color: var(--pulse-blue); text-decoration: none; }
a:hover, .feed-card a:hover, .ticker-link:hover { color: #7dd3fc; text-decoration: underline; }
.form-input, select, textarea, input { color: var(--text-primary) !important; }
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 var(--pulse-glow); }
70% { box-shadow: 0 0 0 12px rgba(56, 189, 248, 0); }
100% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); }
}
@keyframes pulse-ring-green {
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.45); }
70% { box-shadow: 0 0 0 12px rgba(74, 222, 128, 0); }
100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.82); }
}
@keyframes live-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes ticker-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes tg-breathe {
0%, 100% { box-shadow: 0 0 0 rgba(56,189,248,0); }
50% { box-shadow: 0 0 20px rgba(56,189,248,0.08); }
}
@keyframes glow-border {
0%, 100% { border-color: rgba(56, 189, 248, 0.25); }
50% { border-color: rgba(56, 189, 248, 0.55); }
}
/* ——— Pulse buttons (Telegram style) ——— */
.btn-pulse, .btn-pulse-green, .btn-pulse-purple {
position: relative;
font-weight: 600;
color: var(--text-primary) !important;
}
.btn-pulse {
animation: pulse-ring 2.2s infinite;
border: 1px solid var(--pulse-blue) !important;
background: linear-gradient(135deg, rgba(56,189,248,0.2), rgba(56,189,248,0.06)) !important;
}
.btn-pulse-green {
animation: pulse-ring-green 2.2s infinite;
border-color: var(--pulse-green) !important;
background: linear-gradient(135deg, rgba(74,222,128,0.2), rgba(74,222,128,0.06)) !important;
}
.btn-pulse-purple {
animation: pulse-ring 2.2s infinite;
--pulse-glow: rgba(192, 132, 252, 0.45);
border-color: var(--pulse-purple) !important;
background: linear-gradient(135deg, rgba(192,132,252,0.2), rgba(192,132,252,0.06)) !important;
}
.btn-primary {
animation: pulse-ring 3s infinite;
font-weight: 600;
color: var(--text-primary) !important;
}
/* Geen globale panel-beweging — alleen KPI kaarten animeren via hm-neo */
.live-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--pulse-green);
}
.live-badge::before {
content: "";
width: 8px; height: 8px;
border-radius: 50%;
background: var(--pulse-green);
animation: pulse-dot 1.2s ease-in-out infinite;
}
.live-feed-bar {
background: linear-gradient(90deg, #0f172a 20%, #1a3a5c 50%, #0f172a 80%);
background-size: 200% 100%;
animation: live-shimmer 4s linear infinite;
border: 1px solid rgba(56, 189, 248, 0.35);
border-radius: 12px;
padding: 0.65rem 1rem;
margin-bottom: 1rem;
overflow: hidden;
}
.ticker-track { display: flex; gap: 2rem; white-space: nowrap; animation: ticker-scroll 45s linear infinite; }
.ticker-track:hover { animation-play-state: paused; }
.ticker-item, .ticker-link {
background: rgba(56,189,248,0.12);
padding: 0.3rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
color: var(--text-body) !important;
border: 1px solid rgba(56,189,248,0.25);
display: inline-block;
transition: all 0.2s;
}
.ticker-link:hover {
background: rgba(56,189,248,0.25);
color: var(--text-primary) !important;
transform: scale(1.02);
}
.side-nav { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem; }
.side-nav-btn {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.65rem 0.85rem;
border: 1px solid transparent; border-radius: 10px;
background: transparent; color: var(--text-body); cursor: pointer;
text-align: left; font-size: 0.9rem; transition: all 0.2s;
}
.side-nav-btn:hover { background: rgba(56,189,248,0.1); border-color: rgba(56,189,248,0.25); color: var(--text-primary); }
.side-nav-btn.active {
background: rgba(56,189,248,0.18);
border-color: var(--pulse-blue);
box-shadow: 0 0 14px rgba(56,189,248,0.25);
color: var(--text-primary);
}
.section-tabs { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.75rem 0; }
.section-tab {
padding: 0.4rem 0.85rem; border-radius: 999px;
border: 1px solid var(--border-subtle);
background: var(--bg-elevated);
font-size: 0.8rem; cursor: pointer;
color: var(--text-secondary); transition: all 0.2s;
}
.section-tab.active {
border-color: var(--pulse-blue);
background: rgba(56,189,248,0.22);
box-shadow: 0 0 10px rgba(56,189,248,0.3);
color: var(--text-primary);
}
.feed-card {
padding: 0.85rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-subtle);
margin-bottom: 0.6rem;
animation: fade-up 0.4s ease;
transition: border-color 0.2s, box-shadow 0.2s;
background: var(--bg-elevated);
}
.feed-card:hover {
border-color: var(--pulse-blue);
box-shadow: 0 0 16px rgba(56,189,248,0.12);
}
.feed-card a strong { color: var(--text-primary); font-size: 0.95rem; }
.feed-card .feed-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
.feed-card .btn-source {
font-size: 0.75rem; padding: 0.25rem 0.65rem;
border-radius: 999px; border: 1px solid rgba(56,189,248,0.4);
background: rgba(56,189,248,0.1); color: var(--pulse-blue);
cursor: pointer; text-decoration: none; display: inline-block;
}
.feed-card .btn-source:hover { background: rgba(56,189,248,0.22); color: var(--text-primary); }
.feed-card .btn-bookmark { cursor: pointer; border: 1px solid rgba(255,215,0,0.35); background: rgba(255,215,0,0.08); color: #fcd34d; }
.feed-card .btn-bookmark.saved { background: rgba(255,215,0,0.22); color: #ffd700; }
.feed-card.saved-rss { border-left: 3px solid #ffd700; }
.promo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.promo-card-link { display: block; text-decoration: none; color: inherit; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.promo-card-link:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.25); }
.promo-card-link strong, .promo-card-link p, .promo-card-link small { color: inherit; }
.promo-cover { width: 100%; max-height: 180px; object-fit: cover; border-radius: 8px; margin-bottom: 0.65rem; }
.hm-neo-kpi-link { text-decoration: none; color: inherit; display: block; }
.hm-neo-kpi-link:hover .hm-neo-kpi { border-color: rgba(0,229,255,0.35); }
.weather-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.weather-day {
flex: 1; min-width: 70px; text-align: center; padding: 0.5rem;
border-radius: 8px; background: rgba(56,189,248,0.1);
border: 1px solid rgba(56,189,248,0.2); font-size: 0.8rem; color: var(--text-body);
}
.weather-day strong { display: block; font-size: 1.05rem; color: var(--text-primary); }
.note-bubble {
background: rgba(56,189,248,0.1);
border-left: 3px solid var(--pulse-blue);
padding: 0.65rem 0.85rem; border-radius: 0 8px 8px 0;
margin-bottom: 0.5rem; font-size: 0.9rem;
}
.milestone-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid var(--border-subtle); }
.milestone-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pulse-orange); animation: pulse-dot 2s infinite; }
.milestone-dot.done { background: var(--pulse-green); animation: none; }
.pro-tab-bar { display: flex; gap: 0; border-bottom: none; margin-bottom: 1rem; }
/* Neo tabs — shared with Beurs page (beurs.css extends this) */
.neo-tab-bar.pro-tab-bar {
position: relative;
background: rgba(10, 14, 22, 0.9);
border: 1px solid rgba(56,189,248,0.2);
border-radius: 14px;
padding: 0.35rem;
}
.neo-tab-bar .neo-tab, .neo-tab-bar .pro-tab {
flex: 1; border: none; background: transparent;
color: #94a3b8; cursor: pointer; transition: color 0.2s, transform 0.2s;
border-bottom: none !important; margin-bottom: 0 !important;
padding: 0.65rem 0.5rem; font-weight: 600; font-size: 0.82rem;
}
.neo-tab-bar .neo-tab.active, .neo-tab-bar .pro-tab.active {
color: #f8fafc;
background: linear-gradient(135deg, rgba(56,189,248,0.2), rgba(74,222,128,0.08));
border-radius: 10px;
box-shadow: 0 0 16px rgba(56,189,248,0.15);
}
.neo-tab-bar .neo-tab:hover, .neo-tab-bar .pro-tab:hover { color: #e2e8f0; transform: translateY(-1px); }
.pro-tab {
padding: 0.75rem 1.25rem; border: none; background: transparent;
color: var(--text-muted); cursor: pointer; font-size: 0.9rem;
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
}
.pro-tab.active { color: var(--pulse-blue); border-bottom-color: var(--pulse-blue); }
.pro-tab:hover { color: var(--text-primary); }
.kpi-pulse strong {
background: linear-gradient(90deg, var(--pulse-blue), var(--pulse-purple));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
/* ——— Herman dashboard pulse ——— */
.herman-hero {
background: linear-gradient(135deg, rgba(56,189,248,0.12), rgba(192,132,252,0.08));
border: 1px solid rgba(56,189,248,0.3);
border-radius: 16px;
padding: 1.5rem 1.75rem;
margin-bottom: 1.25rem;
animation: glow-border 3s ease-in-out infinite;
}
.herman-hero h1 { margin: 0 0 0.35rem; font-size: 1.75rem; }
.herman-hero .hero-sub { color: var(--text-secondary); font-size: 1rem; margin: 0; }
.pulse-panel {
border: 1px solid rgba(56,189,248,0.2);
border-radius: 12px;
animation: glow-border 4s ease-in-out infinite;
}
.hub-kpi-row .kpi-card, .clickable-kpi {
transition: transform 0.2s, box-shadow 0.2s;
animation: fade-up 0.5s ease;
}
.hub-kpi-row .kpi-card:hover, .clickable-kpi:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(56,189,248,0.15);
}
.hub-feed-item {
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-body);
animation: fade-up 0.35s ease;
}
.briefing-typewriter {
color: var(--text-primary);
line-height: 1.75;
font-size: 1rem;
min-height: 4rem;
}
.briefing-horizon-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
@media (max-width: 900px) { .briefing-horizon-grid { grid-template-columns: 1fr; } }
.horizon-card {
background: var(--bg-elevated);
border-radius: 12px;
padding: 1rem 1.15rem;
border: 1px solid var(--border-subtle);
}
.horizon-card.short { border-color: rgba(251,146,60,0.35); }
.horizon-card.long { border-color: rgba(192,132,252,0.35); }
.horizon-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; }
.horizon-card.short h3 { color: var(--pulse-orange); }
.horizon-card.long h3 { color: var(--pulse-purple); }
.horizon-card ul { margin: 0; padding-left: 1.1rem; color: var(--text-body); line-height: 1.65; }
.horizon-card li { margin-bottom: 0.4rem; }
.retail-highlight {
display: flex; justify-content: space-between; align-items: center;
padding: 0.55rem 0; border-bottom: 1px solid var(--border-subtle);
color: var(--text-body); font-size: 0.9rem;
}
.retail-highlight strong { color: var(--text-primary); }
.panel { color: var(--text-body); }
.panel h2 { color: var(--text-primary); }
.kpi-card span { color: var(--text-secondary) !important; }
.kpi-card strong { color: var(--text-primary) !important; }
+116
View File
@@ -0,0 +1,116 @@
.retail-workspace {
display: grid;
grid-template-columns: 200px 280px 1fr 360px;
gap: 1rem;
min-height: 75vh;
}
.retail-sidebar { overflow-y: auto; max-height: 75vh; }
.retail-filters label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.retail-filters select,
.retail-filters input[type="text"],
.retail-filters input[type="number"] {
width: 100%;
margin-top: 0.25rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border, #334155);
border-radius: 6px;
background: var(--panel-bg, #0f172a);
color: inherit;
}
.retail-filters h4 { margin: 1rem 0 0.5rem; font-size: 0.9rem; }
.retail-filters .checkbox { display: flex; align-items: center; gap: 0.5rem; }
.retail-map-wrap { position: relative; min-height: 500px; }
.retail-map { height: 75vh; min-height: 500px; border-radius: 8px; border: 1px solid #334155; }
.map-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3); border-radius: 8px; pointer-events: none;
}
.retail-panel { overflow-y: auto; max-height: 75vh; }
.retail-panel .detail-section { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #334155; }
.retail-panel .detail-dl {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem 0.5rem; font-size: 0.85rem;
}
.retail-panel .detail-dl dt { opacity: 0.7; }
.retail-panel .detail-dl dd { margin: 0; font-weight: 600; text-align: right; }
.clickable-row { cursor: pointer; }
.clickable-row:hover { opacity: 0.85; }
.retail-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; }
.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; }
.kpi-card {
flex: 1; min-width: 110px; padding: 0.75rem 1rem;
background: var(--panel-bg, #1e293b); border-radius: 8px; border: 1px solid #334155;
}
.kpi-card span { display: block; font-size: 0.75rem; opacity: 0.7; }
.kpi-card strong { font-size: 1.25rem; }
.kpi-green strong { color: #22c55e; }
.retail-main { min-height: 500px; }
.retail-table-wrap { max-height: 75vh; overflow: auto; }
.retail-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.retail-table th, .retail-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #334155; text-align: left; }
.retail-table th { position: sticky; top: 0; background: var(--panel-bg, #1e293b); z-index: 1; }
.trends-ticker { margin-bottom: 1rem; padding: 0.75rem 1rem; overflow-x: auto; }
.ticker-items { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.5rem; }
.ticker-item {
background: rgba(42,171,238,0.1); padding: 0.25rem 0.6rem; border-radius: 999px;
font-size: 0.8rem; white-space: nowrap; border: 1px solid rgba(42,171,238,0.2);
}
.field-picker { margin-top: 1rem; font-size: 0.8rem; }
.field-group { margin: 0.5rem 0; }
.btn-block { width: 100%; margin-top: 0.35rem; }
.muted { opacity: 0.75; font-size: 0.9rem; }
.hint { font-size: 0.8rem; opacity: 0.7; }
.wholesale-list { max-height: 75vh; overflow-y: auto; }
.wholesale-item {
padding: 0.6rem; border-bottom: 1px solid #1e293b; cursor: pointer; font-size: 0.85rem;
}
.wholesale-item:hover { background: rgba(42,171,238,0.06); }
.wholesale-item.active { border-left: 3px solid var(--pulse-blue, #2aabee); }
.filter-group {
border: 1px solid rgba(255,255,255,0.06); border-radius: 8px;
margin-bottom: 0.65rem; padding: 0.35rem 0.5rem; background: rgba(0,0,0,0.15);
}
.filter-group summary {
cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #cbd5e1;
padding: 0.35rem 0; list-style: none;
}
.filter-group summary::-webkit-details-marker { display: none; }
.filter-actions { margin: 0.75rem 0; }
.retail-filters { overflow-y: auto; max-height: 75vh; }
.retail-regs-wrap { max-height: 75vh; overflow-y: auto; }
.regs-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.regs-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
@media (max-width: 1100px) { .regs-columns { grid-template-columns: 1fr; } }
.regs-columns h4 { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.5rem; }
.reg-item { padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.reg-item a { color: #e2e8f0; text-decoration: none; font-size: 0.85rem; }
.reg-item a:hover { color: var(--pulse-blue, #2aabee); }
.reg-item small { display: block; color: #64748b; font-size: 0.7rem; margin-top: 0.15rem; }
.reg-badge {
display: inline-block; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em;
padding: 0.1rem 0.35rem; border-radius: 4px; margin-bottom: 0.2rem;
background: rgba(168,85,247,0.15); color: #a855f7;
}
.reg-badge.cbs { background: rgba(56,189,248,0.15); color: #38bdf8; }
.reg-badge.markt { background: rgba(255,159,67,0.15); color: #ff9f43; }
@media (max-width: 1400px) {
.retail-workspace { grid-template-columns: 180px 260px 1fr; }
.retail-panel { grid-column: 1 / -1; max-height: 50vh; }
}
+29
View File
@@ -0,0 +1,29 @@
:root {
--bg-root: #0a0e14;
--bg-surface: #121820;
--bg-elevated: #182030;
--bg-hover: #1e2a3a;
--border-subtle: #2d3f54;
--border-strong: #3d5268;
--text-primary: #f4f8fc;
--text-secondary: #c8d4e0;
--text-muted: #9fb0c4;
--text-body: #dce6f0;
--accent-cyan: #38bdf8;
--accent-cyan-dim: rgba(56, 189, 248, 0.18);
--accent-purple: #c084fc;
--accent-purple-dim: rgba(192, 132, 252, 0.18);
--accent-amber: #fcd34d;
--accent-amber-dim: rgba(252, 211, 77, 0.18);
--accent-green: #4ade80;
--accent-green-dim: rgba(74, 222, 128, 0.18);
--accent-red: #fb7185;
--accent-red-dim: rgba(251, 113, 133, 0.18);
--shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.45);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--sidebar-width: 240px;
}
+196
View File
@@ -0,0 +1,196 @@
/* Neo topnav — unieke kleur per tab + glow pill effect */
.topnav {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.4rem 0.85rem;
padding-bottom: 0.55rem;
}
/* Shared pill base */
.nav-pill,
.topnav-herman.nav-pill {
--pill-color: #38bdf8;
--pill-glow: rgba(56, 189, 248, 0.45);
--pill-bg: rgba(56, 189, 248, 0.12);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.45rem 0.85rem;
border-radius: 10px;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
color: #cbd5e1;
border: 1px solid color-mix(in srgb, var(--pill-color) 28%, rgba(255, 255, 255, 0.06));
background: rgba(15, 23, 42, 0.6);
transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s, color 0.2s;
overflow: hidden;
isolation: isolate;
}
.nav-pill::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, var(--pill-bg), transparent 70%);
opacity: 0.85;
z-index: -1;
transition: opacity 0.25s;
}
.nav-pill::after {
content: "";
position: absolute;
top: -50%;
left: -60%;
width: 50%;
height: 200%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
transform: skewX(-20deg);
opacity: 0;
transition: opacity 0.3s, left 0.5s;
pointer-events: none;
}
.nav-pill:hover {
transform: translateY(-2px);
color: #f8fafc;
border-color: color-mix(in srgb, var(--pill-color) 50%, transparent);
box-shadow: 0 4px 20px var(--pill-glow), 0 0 0 1px color-mix(in srgb, var(--pill-color) 25%, transparent);
}
.nav-pill:hover::after {
opacity: 1;
left: 120%;
}
.nav-pill.active {
color: #fff;
border-color: var(--pill-color);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent),
0 0 22px var(--pill-glow),
inset 0 0 18px color-mix(in srgb, var(--pill-color) 18%, transparent);
text-shadow: 0 0 12px var(--pill-glow);
animation: nav-pill-pulse 2.5s ease-in-out infinite;
}
.nav-pill.active::before {
opacity: 1;
background: linear-gradient(145deg, color-mix(in srgb, var(--pill-color) 35%, transparent), color-mix(in srgb, var(--pill-color) 8%, transparent));
}
@keyframes nav-pill-pulse {
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), 0 0 18px var(--pill-glow), inset 0 0 14px color-mix(in srgb, var(--pill-color) 15%, transparent); }
50% { box-shadow: 0 0 0 1px var(--pill-color), 0 0 28px var(--pill-glow), inset 0 0 22px color-mix(in srgb, var(--pill-color) 22%, transparent); }
}
/* Herman — reset legacy gold tab, use neo pill */
.topnav-herman.nav-pill {
margin-right: 0;
margin-bottom: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 23, 42, 0.6);
color: #cbd5e1;
box-shadow: none;
}
.topnav-herman.nav-pill.active {
color: #fff;
background: rgba(15, 23, 42, 0.6);
}
.nav-pill-herman {
--pill-color: #fbbf24;
--pill-glow: rgba(251, 191, 36, 0.55);
--pill-bg: rgba(251, 191, 36, 0.2);
font-weight: 700;
font-size: 0.88rem;
padding: 0.5rem 1.1rem;
}
/* CRM */
.nav-pill-clients { --pill-color: #38bdf8; --pill-glow: rgba(56, 189, 248, 0.5); --pill-bg: rgba(56, 189, 248, 0.15); }
.nav-pill-deals { --pill-color: #4ade80; --pill-glow: rgba(74, 222, 128, 0.5); --pill-bg: rgba(74, 222, 128, 0.12); }
.nav-pill-products { --pill-color: #a855f7; --pill-glow: rgba(168, 85, 247, 0.5); --pill-bg: rgba(168, 85, 247, 0.12); }
.nav-pill-suppliers { --pill-color: #fb923c; --pill-glow: rgba(251, 146, 60, 0.5); --pill-bg: rgba(251, 146, 60, 0.12); }
/* Intel */
.nav-pill-beurs { --pill-color: #b8ff3c; --pill-glow: rgba(184, 255, 60, 0.45); --pill-bg: rgba(184, 255, 60, 0.1); }
.nav-pill-retail { --pill-color: #0066cc; --pill-glow: rgba(0, 102, 204, 0.55); --pill-bg: rgba(0, 102, 204, 0.18); }
.nav-pill-browser { --pill-color: #14b8a6; --pill-glow: rgba(20, 184, 166, 0.5); --pill-bg: rgba(20, 184, 166, 0.12); }
.nav-pill-documents { --pill-color: #94a3b8; --pill-glow: rgba(148, 163, 184, 0.4); --pill-bg: rgba(148, 163, 184, 0.1); }
/* Hermes */
.nav-pill-telegram { --pill-color: #2aabee; --pill-glow: rgba(42, 171, 238, 0.55); --pill-bg: rgba(42, 171, 238, 0.15); }
/* Agents */
.nav-pill-agents { --pill-color: #00e5ff; --pill-glow: rgba(0, 229, 255, 0.5); --pill-bg: rgba(0, 229, 255, 0.1); }
.nav-pill-marketing { --pill-color: #ff6b9d; --pill-glow: rgba(255, 107, 157, 0.5); --pill-bg: rgba(255, 107, 157, 0.12); }
.nav-pill-chat { --pill-color: #fcd34d; --pill-glow: rgba(252, 211, 77, 0.45); --pill-bg: rgba(252, 211, 77, 0.12); }
.nav-pill-studio { --pill-color: #ec4899; --pill-glow: rgba(236, 72, 153, 0.5); --pill-bg: rgba(236, 72, 153, 0.12); }
.nav-pill-reports { --pill-color: #6366f1; --pill-glow: rgba(99, 102, 241, 0.5); --pill-bg: rgba(99, 102, 241, 0.12); }
/* System */
.nav-pill-analytics { --pill-color: #8b5cf6; --pill-glow: rgba(139, 92, 246, 0.5); --pill-bg: rgba(139, 92, 246, 0.12); }
.nav-pill-voice { --pill-color: #f97316; --pill-glow: rgba(249, 115, 22, 0.5); --pill-bg: rgba(249, 115, 22, 0.12); }
.nav-pill-ops { --pill-color: #22d3ee; --pill-glow: rgba(34, 211, 238, 0.5); --pill-bg: rgba(34, 211, 238, 0.12); }
.nav-pill-settings { --pill-color: #e2e8f0; --pill-glow: rgba(226, 232, 240, 0.35); --pill-bg: rgba(226, 232, 240, 0.08); }
/* Group labels — subtiel gekleurd */
.topnav-group {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.35rem 0.65rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(0, 0, 0, 0.15);
}
.topnav-group:first-of-type { border-left: none; }
.topnav-group-system { margin-left: auto; }
.topnav-label {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
margin-right: 0.15rem;
padding: 0.2rem 0.35rem;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
}
.topnav-group-crm .topnav-label { color: #38bdf8; }
.topnav-group-intel .topnav-label { color: #b8ff3c; }
.topnav-group-hermes .topnav-label { color: #2aabee; }
.topnav-group-agents .topnav-label { color: #00e5ff; }
/* Override legacy palantir topnav link styles */
.topnav-group a.nav-pill {
margin: 0;
}
.topnav-group a.nav-pill:hover,
.topnav-group a.nav-pill.active {
background: rgba(15, 23, 42, 0.6);
}
.topbar {
padding: 0.65rem 1.25rem 0.35rem;
}
@media (max-width: 900px) {
.topnav-label { display: none; }
.nav-pill { padding: 0.4rem 0.65rem; font-size: 0.75rem; }
.topnav-group { padding: 0.25rem 0.4rem; }
}
@media (prefers-reduced-motion: reduce) {
.nav-pill.active { animation: none; }
.nav-pill:hover { transform: none; }
}
+117
View File
@@ -0,0 +1,117 @@
/* Vertical tab navigation with animated active indicator */
.vtabs-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 1rem;
align-items: start;
min-height: 60vh;
}
.vtabs-nav {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
border-radius: 14px;
background: rgba(10, 14, 20, 0.85);
border: 1px solid rgba(148, 163, 184, 0.12);
overflow: hidden;
}
.vtabs-nav::before {
content: '';
position: absolute;
left: 0;
top: var(--vt-indicator-top, 0);
width: 3px;
height: var(--vt-indicator-h, 40px);
background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
border-radius: 0 4px 4px 0;
transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
box-shadow: 0 0 12px var(--vt-color, #00e5ff);
}
.vtab-btn {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.65rem 0.85rem;
border: none;
border-radius: 10px;
background: transparent;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: color 0.2s, background 0.25s, transform 0.2s;
text-decoration: none;
}
.vtab-btn:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.04);
transform: translateX(4px);
}
.vtab-btn.active {
color: #fff;
background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent);
text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent);
}
.vtab-icon { font-size: 1.1rem; flex-shrink: 0; }
.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; }
@keyframes vtabFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* App sidebar layout */
.app-shell {
display: grid;
grid-template-columns: 200px 1fr;
min-height: 100vh;
}
.app-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: 1rem 0.65rem;
background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95));
border-right: 1px solid rgba(148, 163, 184, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.app-sidebar-brand {
padding: 0.5rem 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
margin-bottom: 0.5rem;
}
.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; }
.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; }
.app-sidebar-group { margin-bottom: 0.75rem; }
.app-sidebar-label {
font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em;
color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem;
}
.app-sidebar .nav-pill {
display: flex; width: 100%; justify-content: flex-start;
margin-bottom: 0.2rem; border-radius: 10px;
animation: none;
}
.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; }
.app-main { min-width: 0; }
.app-topbar {
display: flex; justify-content: space-between; align-items: center;
padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(8, 12, 18, 0.6);
}
.app-clock {
font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8;
}
.app-clock strong { color: #e2e8f0; }
.main-topnav { padding: 1rem 1.25rem; }
/* Mobile layout → see mobile.css */
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img">
<rect width="512" height="512" rx="96" fill="#0a0e14"/>
<rect x="32" y="32" width="448" height="448" rx="80" fill="none" stroke="#fbbf24" stroke-width="12"/>
<text x="256" y="290" text-anchor="middle" font-size="200" font-family="system-ui,sans-serif" fill="#fbbf24">F</text>
<circle cx="400" cy="112" r="28" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

+109
View File
@@ -0,0 +1,109 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
function make(tag, attrs = {}) {
const el = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
return el;
}
function clear(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function curvePath(x1, y1, x2, y2) {
const cx1 = x1 + (x2 - x1) * 0.35;
const cy1 = y1;
const cx2 = x1 + (x2 - x1) * 0.72;
const cy2 = y2;
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}
async function loadMeshData() {
const r = await fetch('/api/agents/mesh');
if (!r.ok) throw new Error('Mesh API unavailable');
return r.json();
}
function drawMesh(svg, data) {
clear(svg);
const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1000;
const height = vb && vb.height ? vb.height : 620;
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(width, height) * 0.35;
const souls = (data.nodes || []).filter((n) => (n.agent_key || '').toLowerCase() !== 'herman');
const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
souls.forEach((node, i) => {
const a = (Math.PI * 2 * i) / Math.max(1, souls.length) - Math.PI / 2;
node._x = cx + Math.cos(a) * radius;
node._y = cy + Math.sin(a) * radius;
});
const edgesLayer = make('g');
const nodesLayer = make('g');
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
souls.forEach((node) => {
const pathDef = curvePath(node._x, node._y, cx, cy);
const edge = make('path', { d: pathDef, class: 'mesh-edge' });
edgesLayer.appendChild(edge);
if (edgesBySource[node.agent_key]) {
const pulse = make('path', { d: pathDef, class: 'mesh-pulse' });
pulse.style.animationDuration = `${Math.max(1.2, 3.5 - Math.min(2.2, edgesBySource[node.agent_key].weight / 8))}s`;
edgesLayer.appendChild(pulse);
}
});
const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy, r: 62 }));
herman.appendChild(make('circle', { class: 'main', cx, cy, r: 46 }));
const crown = make('text', { x: cx, y: cy - 2, 'font-size': 24, 'text-anchor': 'middle' });
crown.textContent = '👑';
herman.appendChild(crown);
const label = make('text', { x: cx, y: cy + 22 });
label.textContent = 'Herman · Co-CEO';
herman.appendChild(label);
nodesLayer.appendChild(herman);
souls.forEach((node) => {
const group = make('g', { class: `mesh-node ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 24 }));
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName;
group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 42, 'font-size': 10, opacity: 0.82 });
role.textContent = node.role_title || node.agent_key;
group.appendChild(role);
group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) {
Cockpit.toast(`${node.display_name || node.agent_key}: ${node.health || 'idle'}`, 'success');
}
});
nodesLayer.appendChild(group);
});
}
async function mount(targetId) {
const svg = document.getElementById(targetId);
if (!svg) return;
try {
const data = await loadMeshData();
drawMesh(svg, data);
} catch (e) {
clear(svg);
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
msg.textContent = 'Mesh kon niet geladen worden';
svg.appendChild(msg);
}
}
window.AgentsMesh = { mount };
})();
+181
View File
@@ -0,0 +1,181 @@
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) {
if (!data) return;
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
barChart('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');
barChart('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');
barChart('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');
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); }));
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
barChart('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);
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); }));
barChart('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); }));
barChart('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);
barChart('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);
barChart('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);
}
return { render: render, destroyAll: destroyAll };
})();
function analyticsHub() {
return {
loading: false,
updatedAt: '—',
liveLabel: 'Live',
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
meta: {},
_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);
},
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]);
});
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');
} catch (e) {
if (toast !== false) Cockpit.toast(e.message, 'error');
} finally {
this.loading = false;
}
},
resetFilters() {
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
this.refresh();
},
};
}
+100
View File
@@ -0,0 +1,100 @@
window.beursHub = function () {
return {
tab: new URLSearchParams(location.search).get('tab') || 'beurs',
busy: false,
lastRefresh: '',
listed: [],
unlisted: [],
summary: {},
trends: { halal_meat_trends: [], top_dishes: [], market_trends: [] },
concepts: [],
events: [],
platformStats: {},
eventFilter: '',
pollTimer: null,
indicatorStyle: { left: '0%', width: '25%' },
init() {
this.updateIndicator();
this.refreshAll();
this.pollTimer = setInterval(() => {
if (this.tab === 'events') this.loadEvents(false);
}, 5000);
},
setTab(t) {
this.tab = t;
const url = new URL(location.href);
url.searchParams.set('tab', t);
history.replaceState({}, '', url);
this.updateIndicator();
if (t === 'trends' && !this.trends.halal_meat_trends?.length) this.loadTrends();
if (t === 'concepten' && !this.concepts.length) this.loadConcepts();
if (t === 'events') this.loadEvents();
},
updateIndicator() {
const tabs = ['beurs', 'trends', 'concepten', 'events'];
const i = tabs.indexOf(this.tab);
const w = 100 / tabs.length;
this.indicatorStyle = { left: (i * w) + '%', width: w + '%' };
},
sparkPoints(arr) {
if (!arr || !arr.length) return '';
const min = Math.min.apply(null, arr);
const max = Math.max.apply(null, arr);
const range = max - min || 1;
return arr.map(function (v, idx) {
var x = (idx / (arr.length - 1 || 1)) * 100;
var y = 28 - ((v - min) / range) * 24;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
},
async refreshAll() {
this.busy = true;
try {
await Promise.all([
this.loadMarket(),
this.loadTrends(),
this.loadConcepts(),
this.loadEvents(false),
]);
this.lastRefresh = new Date().toLocaleString('nl-NL');
if (typeof Cockpit !== 'undefined') Cockpit.toast('Beurs data bijgewerkt', 'success');
} catch (e) {
if (typeof Cockpit !== 'undefined') Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
async loadMarket() {
const d = await fetch('/api/retail/market/supermarkets').then(function (r) { return r.json(); });
this.listed = d.listed || [];
this.unlisted = d.unlisted_nl || [];
this.summary = d.summary || {};
},
async loadTrends() {
const d = await fetch('/api/retail/market/food-trends').then(function (r) { return r.json(); });
this.trends = d;
},
async loadConcepts() {
const d = await fetch('/api/retail/market/concepts?limit=6').then(function (r) { return r.json(); });
this.concepts = d.concepts || [];
},
async loadEvents(toast) {
var q = this.eventFilter ? '?limit=80&agent=' + encodeURIComponent(this.eventFilter) : '?limit=80';
try {
var r = await fetch('/api/live/platform' + q).then(function (x) { return x.json(); });
this.events = r.events || [];
this.platformStats = r.stats || {};
} catch (e) {
if (toast !== false && typeof Cockpit !== 'undefined') Cockpit.toast(e.message, 'error');
}
},
};
};
+347
View File
@@ -0,0 +1,347 @@
window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function parseContent(content) {
var parts = (content || '').split(/\n---\n/);
var ai = parts[0] || '';
var summary = '', actions = [], longTerm = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
return { summary: summary, actions: actions, longTerm: longTerm };
}
function typewriter(el, text, speed) {
if (!el) return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
var i = 0;
typewriterTimer = setInterval(function () {
if (i < text.length) { el.textContent += text.charAt(i); i++; }
else clearInterval(typewriterTimer);
}, speed || 8);
}
function countUp(el, end, prefix, suffix) {
if (!el) return;
prefix = prefix || ''; suffix = suffix || '';
var start = 0, dur = 600, t0 = performance.now();
function step(t) {
var p = Math.min(1, (t - t0) / dur);
var v = Math.round(start + (end - start) * p);
el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function chartColors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function renderPipeline(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
if (charts.pipeline) charts.pipeline.destroy();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderSentiment(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; });
if (!files.length) counts.neutral = 1;
var c = chartColors();
if (charts.sentiment) charts.sentiment.destroy();
charts.sentiment = new Chart(canvas, {
type: 'doughnut',
data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
});
}
function renderWords(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
if (charts.words) charts.words.destroy();
charts.words = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderAgents(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var map = {};
(stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
var labels = Object.keys(map), c = chartColors();
if (charts.agents) charts.agents.destroy();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } },
});
}
function eqBars() {
return '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function sparklineSvg(values, trend) {
if (!values || !values.length) return '';
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = max - min || 1;
var pts = values.map(function (v, i) {
var x = (i / (values.length - 1 || 1)) * 100;
var y = 100 - ((v - min) / range) * 80 - 10;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var color = trend === 'down' ? '#fb7185' : '#4ade80';
return '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
}
function stockEqBars(trend) {
var cls = trend === 'down' ? ' hm-eq-down' : '';
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function renderStocksMini(container, stats) {
if (!container) return;
var stocks = (stats.market_stocks || []).slice(0, 4);
var summary = stats.market_summary || {};
if (!stocks.length) {
container.innerHTML = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
'<strong>' + (s.symbol || s.name) + '</strong> ' +
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
}
function renderStocks(container, stats) {
if (!container) return;
var stocks = stats.market_stocks || [];
var summary = stats.market_summary || {};
var meta = document.getElementById('market-updated-at');
if (meta) {
var avg = summary.avg_change_pct;
meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
}
if (!stocks.length) {
container.innerHTML = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
var up = pct >= 0;
var price = s.price != null ? Number(s.price).toFixed(2) : '—';
var cur = s.currency || 'EUR';
return '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
}).join('');
}
function renderFoodHighlights(container, stats) {
if (!container) return;
var items = stats.food_market_highlights || stats.rss_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen highlights — <a href="/marketing">RSS ophalen</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = (r.category || 'markt').toUpperCase();
return '<div class="hm-highlight-item"><span class="hm-cat-badge">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderRegulations(container, stats) {
if (!container) return;
var items = stats.regulation_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderTrends(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.market_trends || [];
var c = chartColors();
if (charts.trends) charts.trends.destroy();
if (!rows.length) return;
charts.trends = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
datasets: [{
label: 'Kans %',
data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
borderRadius: 6,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
scales: {
y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
},
},
});
}
function renderKpis(container, stats) {
if (!container) return;
var summary = stats.market_summary || {};
var avgPct = Number(summary.avg_change_pct || 0);
var best = summary.best_performer || {};
var items = [
{ label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', pct: '72%', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL') },
{ label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' },
{ label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' },
{ label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') },
{ label: 'Halal kansen', sub: 'top score', icon: '🎯', color: '#a855f7', pct: '65%', val: stats.top_opportunities && stats.top_opportunities[0] ? Math.round(Number(stats.top_opportunities[0].halal_opportunity_score || 0)) + '/100' : '—' },
{ label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
];
container.className = 'hm-neo-kpi-row';
container.innerHTML = items.map(function (it) {
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : inner;
}).join('');
}
function renderRetail(container, stats) {
if (!container) return;
var opps = stats.top_opportunities || [];
if (!opps.length) { container.innerHTML = '<p class="empty-state">Geen kansen — <a href="/retail">open Retail 360</a></p>'; return; }
container.innerHTML = opps.map(function (o) {
var score = Math.round(Number(o.halal_opportunity_score) || 0);
return '<a href="/retail" class="retail-highlight"><span><strong>' + (o.chain || '') + ' · ' + (o.name || '') + '</strong><br><small>' + (o.city || '') + '</small></span><span class="ticker-item">' + score + '/100</span></a>';
}).join('');
}
function renderMilestones(container, stats) {
if (!container) return;
var ms = stats.milestones_pending || [];
if (!ms.length) {
container.innerHTML = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
return;
}
container.innerHTML = ms.map(function (m) {
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
}).join('');
}
function renderExecutiveSummary(container, stats) {
if (!container) return;
var items = [
{ icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
{ icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
{ icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
{ icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
{ icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
{ icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var opps = (stats.top_opportunities || []).slice(0, 3);
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 (opps.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Top halal kansen</h4><ul class="hm-exec-list">';
opps.forEach(function (o) {
html += '<li><a href="/retail">' + (o.chain || '') + ' · ' + (o.name || '') + ' (' + (o.city || '') + ') — ' + Math.round(Number(o.halal_opportunity_score || 0)) + '/100</a></li>';
});
html += '</ul>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
ms.forEach(function (m) {
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">RSS & reclame folders → <a href="/marketing">Marketing Hub</a> · Volledige analytics → <a href="/analytics">Analytics</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8);
var actEl = document.getElementById('briefing-actions-list');
if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Genereer dagrapport voor actiepunten</li>';
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
}
function renderLive(root, stats) {
if (!root || !stats) return;
renderKpis(document.getElementById('briefing-kpis'), stats);
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);
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderWords(document.getElementById('chart-words'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
function render(root, stats, content, createdAt) {
renderLive(root, stats);
renderText(root, content, createdAt);
}
return { render: render, renderLive: renderLive, renderText: renderText, renderExecutiveSummary: renderExecutiveSummary, destroyAll: destroyAll };
})();
+75
View File
@@ -0,0 +1,75 @@
window.Cockpit = (function () {
const API = '/api/admin';
function toast(message, type) {
type = type || 'info';
let root = document.getElementById('cockpit-toasts');
if (!root) {
root = document.createElement('div');
root.id = 'cockpit-toasts';
root.className = 'toast-container';
document.body.appendChild(root);
}
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.textContent = message;
root.appendChild(el);
setTimeout(function () { el.classList.add('toast-out'); setTimeout(function () { el.remove(); }, 300); }, 3500);
}
function clearEmbeddedIframes() {
document.querySelectorAll('iframe.browser-novnc, iframe[data-clear-on-nav]').forEach(function (f) {
try { f.src = 'about:blank'; } catch (e) {}
});
}
async function request(path, options) {
options = options || {};
const url = path.startsWith('http') || path.startsWith('/api/') ? path : API + path;
const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
const fetchOpts = Object.assign({}, options, { headers: headers });
if (options.signal) fetchOpts.signal = options.signal;
const res = await fetch(url, fetchOpts);
let data = null;
try { data = await res.json(); } catch (e) { data = null; }
if (!res.ok) {
const msg = (data && (data.detail || data.message)) || res.statusText;
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
}
return data;
}
function confirmDelete(message) {
return window.confirm(message || 'Delete this item?');
}
function openDrawer(id) {
document.body.classList.add('drawer-open');
var el = document.getElementById(id);
if (el) el.classList.add('open');
}
function closeDrawer(id) {
document.body.classList.remove('drawer-open');
if (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('open');
}
document.querySelectorAll('.drawer.open').forEach(function (d) { d.classList.remove('open'); });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
document.addEventListener('click', function (e) {
var link = e.target.closest('a[href]');
if (!link || link.target === '_blank' || link.hasAttribute('download')) return;
var href = link.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return;
if (href.indexOf('6080') !== -1 || href.indexOf('7788') !== -1) return;
if (href.charAt(0) === '/' || href.indexOf('http') === 0) clearEmbeddedIframes();
}, true);
return { toast: toast, api: request, confirmDelete: confirmDelete, openDrawer: openDrawer, closeDrawer: closeDrawer, clearIframes: clearEmbeddedIframes };
})();
+346
View File
@@ -0,0 +1,346 @@
/**
* Hermes Neo Command Center
*/
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' },
];
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,
}));
},
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 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(); }
},
formatTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch { return iso; }
},
paStatusLabel() {
const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' };
return m[this.paLive.status] || this.paLive.status || 'Idle';
},
slotStatusLabel(s) {
const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' };
return m[s] || s || 'Idle';
},
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() {
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); }
},
async refreshUsers() {
try {
const data = await Cockpit.api('/hermes/users');
this.users = data.users || [];
} catch (e) { console.warn('users', e); }
},
async refreshConversations() {
try {
const data = await Cockpit.api('/hermes/conversations');
this.conversations = data.items || [];
} catch (e) { console.warn('conversations', e); }
},
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 refreshPaLive(quiet) {
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;
}
} catch (e) {
if (!quiet) console.warn('pa live', e);
}
},
async loadGraph() {
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);
} catch (e) {
Cockpit.toast('Graph laden mislukt: ' + e.message, 'error');
}
},
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 },
});
},
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;
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 }),
});
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;
}
},
};
}
+54
View File
@@ -0,0 +1,54 @@
window.CockpitLive = (function () {
var ws = null;
var reconnectTimer = null;
function startPolling(fn, intervalMs) {
intervalMs = intervalMs || 30000;
fn();
var id = setInterval(fn, intervalMs);
return function () { clearInterval(id); };
}
function connectFeed(onMessage) {
if (typeof WebSocket === 'undefined') return function () {};
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
function connect() {
try {
ws = new WebSocket(proto + '//' + location.host + '/ws/feed');
ws.onmessage = function (ev) {
try {
var data = JSON.parse(ev.data);
if (onMessage) onMessage(data);
} catch (e) {}
};
ws.onclose = function () {
reconnectTimer = setTimeout(connect, 5000);
};
} catch (e) {
reconnectTimer = setTimeout(connect, 5000);
}
}
connect();
return function () {
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
};
}
function updateLiveBadge(el, at) {
if (!el) return;
el.textContent = 'Live · ' + (at || new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }));
}
function renderAgentFeed(container, events) {
if (!container || !events) return;
container.innerHTML = events.slice(0, 12).map(function (ev) {
var t = (ev.created_at || '').substring(11, 16);
return '<div class="hub-feed-item"><span>' + t + '</span>' +
'<span class="badge badge-agent-' + String(ev.agent_name || '').toLowerCase() + '">' + (ev.agent_name || '') + '</span> ' +
(ev.title || ev.event_type || '') + '</div>';
}).join('') || '<p class="empty-state">Nog geen events.</p>';
}
return { startPolling: startPolling, connectFeed: connectFeed, updateLiveBadge: updateLiveBadge, renderAgentFeed: renderAgentFeed };
})();
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Foodlinkk Command Center",
"short_name": "Foodlinkk",
"description": "Herman CEO dashboard · CRM · Retail · Marketing",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0e14",
"theme_color": "#0a0e14",
"lang": "nl",
"icons": [
{
"src": "/static/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/static/icons/app-icon.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+180
View File
@@ -0,0 +1,180 @@
/* Retail 360 workspace layout */
.retail-workspace {
display: grid;
grid-template-columns: 200px 1fr 360px;
gap: 1rem;
min-height: 75vh;
align-items: start;
}
.retail-workspace.with-filters {
grid-template-columns: 200px 300px 1fr 360px;
}
.retail-sidebar { overflow-y: auto; max-height: 75vh; position: sticky; top: 0.5rem; }
/* Filter panel */
.retail-filters {
overflow-y: auto;
max-height: 75vh;
position: sticky;
top: 0.5rem;
padding: 0.85rem 1rem !important;
}
.filters-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.75rem; padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.filters-head h3 { margin: 0; font-size: 1rem; }
.filter-count {
font-size: 0.65rem; padding: 0.15rem 0.45rem; border-radius: 999px;
background: rgba(42,171,238,0.15); color: #38bdf8;
}
.filter-group {
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
margin-bottom: 0.55rem;
background: rgba(0,0,0,0.2);
overflow: hidden;
}
.filter-group summary {
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
color: #e2e8f0;
padding: 0.55rem 0.65rem;
list-style: none;
user-select: none;
}
.filter-group summary::-webkit-details-marker { display: none; }
.filter-group[open] summary {
border-bottom: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.02);
}
.filter-group-body {
padding: 0.55rem 0.65rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0;
width: 100%;
}
.filter-label {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: #94a3b8;
letter-spacing: 0.02em;
}
.filter-field select,
.filter-field input[type="text"],
.filter-field input[type="number"] {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: #0a0f18;
color: #f1f5f9;
font-size: 0.85rem;
margin: 0;
}
.filter-field select:focus,
.filter-field input:focus {
outline: none;
border-color: rgba(42,171,238,0.5);
box-shadow: 0 0 0 2px rgba(42,171,238,0.15);
}
.filter-field.filter-check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 0.15rem 0;
}
.filter-field.filter-check input[type="checkbox"] {
width: 16px; height: 16px; flex-shrink: 0; margin: 0;
accent-color: #2aabee;
}
.filter-field.filter-check span {
font-size: 0.82rem;
color: #cbd5e1;
}
.filter-actions { margin: 0.65rem 0; display: flex; flex-direction: column; gap: 0.35rem; }
.filter-sync { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(255,255,255,0.06); }
.filter-sync h4 { margin: 0 0 0.5rem; font-size: 0.85rem; color: #94a3b8; }
.retail-map-wrap { position: relative; min-height: 500px; }
.retail-map { height: 75vh; min-height: 500px; border-radius: 8px; border: 1px solid #334155; }
.map-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3); border-radius: 8px; pointer-events: none;
}
.retail-panel { overflow-y: auto; max-height: 75vh; position: sticky; top: 0.5rem; }
.retail-panel .detail-section { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #334155; }
.retail-panel .detail-dl {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem 0.5rem; font-size: 0.85rem;
}
.retail-panel .detail-dl dt { opacity: 0.7; }
.retail-panel .detail-dl dd { margin: 0; font-weight: 600; text-align: right; }
.clickable-row { cursor: pointer; }
.clickable-row:hover { opacity: 0.85; }
.retail-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; }
.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; }
.kpi-card {
flex: 1; min-width: 110px; padding: 0.75rem 1rem;
background: var(--panel-bg, #1e293b); border-radius: 8px; border: 1px solid #334155;
}
.kpi-card span { display: block; font-size: 0.75rem; opacity: 0.7; }
.kpi-card strong { font-size: 1.25rem; }
.kpi-green strong { color: #22c55e; }
.retail-main { min-height: 500px; min-width: 0; }
.retail-table-wrap { max-height: 75vh; overflow: auto; }
.retail-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.retail-table th, .retail-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #334155; text-align: left; }
.retail-table th { position: sticky; top: 0; background: var(--panel-bg, #1e293b); z-index: 1; }
.trends-ticker { margin-bottom: 1rem; padding: 0.75rem 1rem; overflow-x: auto; }
.ticker-items { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.5rem; }
.ticker-item {
background: rgba(42,171,238,0.1); padding: 0.25rem 0.6rem; border-radius: 999px;
font-size: 0.8rem; white-space: nowrap; border: 1px solid rgba(42,171,238,0.2);
}
.field-picker { margin-top: 1rem; font-size: 0.8rem; }
.field-group { margin: 0.5rem 0; }
.btn-block { width: 100%; margin-top: 0; }
.muted { opacity: 0.75; font-size: 0.9rem; }
.hint { font-size: 0.8rem; opacity: 0.7; }
.wholesale-list { max-height: 75vh; overflow-y: auto; }
.wholesale-item {
padding: 0.6rem; border-bottom: 1px solid #1e293b; cursor: pointer; font-size: 0.85rem;
}
.wholesale-item:hover { background: rgba(42,171,238,0.06); }
.wholesale-item.active { border-left: 3px solid var(--pulse-blue, #2aabee); }
@media (max-width: 1400px) {
.retail-workspace.with-filters { grid-template-columns: 180px 280px 1fr; }
.retail-panel { grid-column: 1 / -1; max-height: 45vh; position: static; }
}
@media (max-width: 900px) {
.retail-workspace,
.retail-workspace.with-filters { grid-template-columns: 1fr; }
.retail-sidebar, .retail-filters, .retail-panel { position: static; max-height: none; }
}
+247
View File
@@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="/static/retail.css" />
<div x-data="retailIntel()" x-init="init()">
<header class="page-header">
<div>
<h1>Retail Intelligence</h1>
<p class="subtitle">Supermarkten · CBS demografie · PDOK · partnerships</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm" @click="applyFilters()" :disabled="loading">Vernieuwen</button>
<button class="btn btn-sm btn-primary" @click="runEnrich()" :disabled="enriching">
<span x-text="enriching ? 'CBS laden…' : 'CBS/PDOK verrijken'"></span>
</button>
</div>
</header>
<div class="kpi-row">
<div class="kpi-card"><span>Totaal</span><strong x-text="stats.total || 0"></strong></div>
<div class="kpi-card kpi-green"><span>Actief</span><strong x-text="stats.active_partnerships || 0"></strong></div>
<div class="kpi-card"><span>CBS data</span><strong x-text="stats.with_area_data || 0"></strong></div>
<div class="kpi-card"><span>Gem. inkomen</span><strong x-text="formatEuro(stats.avg_area_income)"></strong></div>
<div class="kpi-card"><span>Gem. halal-markt*</span><strong x-text="formatPct(stats.avg_muslim_proxy_pct)"></strong></div>
<div class="kpi-card"><span>Resultaat</span><strong x-text="mapCount"></strong></div>
</div>
<div class="retail-layout">
<aside class="retail-filters panel">
<h3>Filters</h3>
<label>Keten
<select x-model="f.chain" @change="applyFilters()">
<option value="">Alle ketens</option>
<template x-for="c in meta.chains" :key="c.chain">
<option :value="c.chain" x-text="`${c.chain} (${c.n})`"></option>
</template>
</select>
</label>
<label>Provincie
<select x-model="f.province" @change="applyFilters()">
<option value="">Alle provincies</option>
<template x-for="p in meta.provinces" :key="p.province">
<option :value="p.province" x-text="`${p.province} (${p.n})`"></option>
</template>
</select>
</label>
<label>Partnership
<select x-model="f.partnership" @change="applyFilters()">
<option value="">Alle</option>
<option value="active">Actief</option>
<option value="proposal">Voorstel</option>
<option value="none">Geen</option>
</select>
</label>
<label>Postcode prefix<input type="text" x-model="f.postcode_prefix" maxlength="4" placeholder="10" @change="applyFilters()"></label>
<label>Zoeken<input type="text" x-model="f.q" placeholder="Naam, adres, stad" @keyup.enter="applyFilters()"></label>
<label class="checkbox"><input type="checkbox" x-model="f.halal_certified" @change="applyFilters()"> Halal gecertificeerd</label>
<label class="checkbox"><input type="checkbox" x-model="f.has_area_data" @change="applyFilters()"> Alleen met CBS data</label>
<h4>CBS demografie (gemeente-niveau)</h4>
<label>Min. bevolking<input type="number" x-model.number="f.min_population" min="0" step="1000" @change="applyFilters()"></label>
<label>Min. gem. inkomen (€)<input type="number" x-model.number="f.min_avg_income" min="0" step="1000" @change="applyFilters()"></label>
<label>Min. halal-markt proxy (%)*<input type="number" x-model.number="f.min_muslim_pct" min="0" max="100" step="0.5" @change="applyFilters()"></label>
<p class="hint">*Indicatief: Marokko+Turkije / bevolking (CBS)</p>
<button class="btn btn-sm" @click="resetFilters()">Reset filters</button>
<div class="enrich-status" x-show="enrichment">
<h4>Databronnen</h4>
<p><span x-text="enrichment.enriched_postcodes || 0"></span> / <span x-text="enrichment.unique_postcodes || 0"></span> postcodes verrijkt</p>
<ul class="provider-list">
<template x-for="p in (enrichment.data_providers || [])" :key="p.name">
<li><span x-text="p.name"></span> <small x-text="p.last_status || '—'"></small></li>
</template>
</ul>
</div>
</aside>
<div class="retail-map-wrap">
<div id="map" class="retail-map"></div>
<div class="map-loading" x-show="loading">Kaart laden…</div>
</div>
<aside class="retail-panel panel">
<template x-if="selected">
<div class="store-detail">
<h3 x-text="selected.name"></h3>
<p class="muted"><span x-text="selected.chain"></span> · <span x-text="selected.city"></span> · <span x-text="selected.postcode"></span></p>
<p x-text="selected.address"></p>
<div class="detail-section">
<h4>Partnership</h4>
<p>Status: <strong x-text="selected.partnership_status"></strong></p>
<p x-show="selected.halal_certified">✅ Halal gecertificeerd</p>
</div>
<template x-if="detail.area_analysis">
<div class="detail-section">
<h4>CBS Demografie</h4>
<p class="hint" x-text="detail.area_analysis.data_source"></p>
<dl class="detail-dl">
<dt>Bevolking</dt><dd x-text="formatNum(detail.area_analysis.population)"></dd>
<dt>Huishoudens</dt><dd x-text="formatNum(detail.area_analysis.households)"></dd>
<dt>Gem. inkomen</dt><dd x-text="formatEuro(detail.area_analysis.avg_income)"></dd>
<dt>Halal-markt proxy</dt><dd x-text="formatPct(detail.area_analysis.religious_composition?.muslim_proxy_pct)"></dd>
<dt>Niet-Westers %</dt><dd x-text="formatPct(detail.area_analysis.ethnic_composition?.niet_westers_pct)"></dd>
</dl>
</div>
</template>
<template x-if="!detail.area_analysis">
<p class="hint">Geen CBS data — klik 'CBS/PDOK verrijken'</p>
</template>
<template x-if="detail.nearby_stores && detail.nearby_stores.length">
<div class="detail-section">
<h4>Concurrentie (&lt;3 km)</h4>
<ul class="nearby-list">
<template x-for="n in detail.nearby_stores" :key="n.id">
<li @click="selectStore(n.id)" class="clickable-row">
<span x-text="n.chain"></span><span x-text="n.name"></span>
<small x-text="`${n.distance_km?.toFixed?.(1) || n.distance_km} km`"></small>
</li>
</template>
</ul>
</div>
</template>
<template x-if="detail.recommendations && detail.recommendations.length">
<div class="detail-section">
<h4>Herman</h4>
<template x-for="r in detail.recommendations" :key="r.id">
<p><strong x-text="r.title"></strong><br><small x-text="r.description"></small></p>
</template>
</div>
</template>
</div>
</template>
<template x-if="!selected">
<p class="muted">Klik een marker voor CBS-data, concurrentie en Herman-tips.</p>
<h4>Herman Top picks</h4>
<ul class="reco-list">
{% for r in recommendations %}
<li><strong>{{ r.title }}</strong><br><small>{{ r.priority }} · {{ r.recommendation_type }}</small></li>
{% endfor %}
</ul>
</template>
</aside>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script>
function retailIntel(){
return {
map: null, cluster: null, markers: [],
stats: {{ stats | tojson }},
meta: {{ filters | tojson }},
enrichment: {{ enrichment | tojson }},
f: { chain:'', province:'', partnership:'', postcode_prefix:'', q:'',
halal_certified:false, has_area_data:false,
min_population:null, min_avg_income:null, min_muslim_pct:null },
selected: null, detail: {}, loading: false, enriching: false, mapCount: 0,
init(){
this.map = L.map('map').setView([52.15, 5.3], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(this.map);
this.cluster = L.markerClusterGroup();
this.map.addLayer(this.cluster);
this.applyFilters();
},
params(){
const p = new URLSearchParams();
Object.entries(this.f).forEach(([k,v]) => {
if (v === null || v === '' || v === false) return;
p.set(k, v);
});
return p.toString();
},
async applyFilters(){
this.loading = true;
try {
const [stats, mapData] = await Promise.all([
fetch('/api/retail/stats?' + this.params()).then(r=>r.json()),
fetch('/api/retail/map?' + this.params()).then(r=>r.json()),
]);
this.stats = stats;
this.mapCount = mapData.count || 0;
this.renderMarkers(mapData.items || []);
} finally { this.loading = false; }
},
renderMarkers(stores){
this.cluster.clearLayers();
this.markers = [];
const colors = { 'Albert Heijn':'#0066cc', 'Jumbo':'#ffcc00', 'Plus':'#008040',
'Lidl':'#0050aa', 'ALDI':'#0066b3', 'Dirk':'#e30613' };
stores.forEach(s => {
if (!s.latitude || !s.longitude) return;
const active = s.partnership_status === 'active';
const color = active ? '#22c55e' : (colors[s.chain] || '#64748b');
const m = L.circleMarker([+s.latitude, +s.longitude], {
radius: active ? 10 : 7, color, weight: 2, fillColor: color, fillOpacity: 0.85
});
m.storeId = s.id;
m.on('click', () => this.selectStore(s.id));
m.bindTooltip(`${s.chain}: ${s.name}`, { direction: 'top' });
this.cluster.addLayer(m);
this.markers.push(m);
});
},
async selectStore(id){
const d = await fetch(`/api/retail/supermarkets/${id}`).then(r=>r.json());
this.selected = d;
this.detail = d;
if (d.latitude && d.longitude) this.map.setView([+d.latitude, +d.longitude], 14);
},
resetFilters(){
this.f = { chain:'', province:'', partnership:'', postcode_prefix:'', q:'',
halal_certified:false, has_area_data:false,
min_population:null, min_avg_income:null, min_muslim_pct:null };
this.selected = null; this.detail = {};
this.applyFilters();
},
async runEnrich(){
this.enriching = true;
try {
await fetch('/api/retail/enrich?limit=100', { method:'POST' });
this.enrichment = await fetch('/api/retail/filters').then(r=>r.json()).then(d=>d.enrichment);
await this.applyFilters();
} finally { this.enriching = false; }
},
formatEuro(v){ return v ? '€ ' + Math.round(v).toLocaleString('nl-NL') : '—'; },
formatNum(v){ return v ? Number(v).toLocaleString('nl-NL') : '—'; },
formatPct(v){ return (v !== null && v !== undefined) ? v + '%' : '—'; },
};
}
</script>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
"""Retail intelligence map page + API proxy."""
from __future__ import annotations
import os
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter()
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
async def _tools_get(path: str, params: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{TOOLS}{path}", params=params or {})
resp.raise_for_status()
return resp.json()
@router.get("/retail")
async def retail_page(request: Request):
stats = await _tools_get("/retail/stats")
filters = await _tools_get("/retail/filters")
enrich = await _tools_get("/retail/enrich/status")
recs = await _tools_get("/recommendations/pending", {"limit": 5})
return templates.TemplateResponse(
"retail.html",
{
"request": request,
"stats": stats,
"filters": filters,
"enrichment": enrich,
"recommendations": recs.get("items", []),
},
)
@router.get("/api/retail/stats")
async def api_retail_stats(request: Request):
return JSONResponse(await _tools_get("/retail/stats", dict(request.query_params)))
@router.get("/api/retail/filters")
async def api_retail_filters():
return JSONResponse(await _tools_get("/retail/filters"))
@router.get("/api/retail/map")
async def api_retail_map(request: Request):
return JSONResponse(await _tools_get("/retail/map", dict(request.query_params)))
@router.get("/api/retail/supermarkets/{store_id}")
async def api_retail_store(store_id: int):
return JSONResponse(await _tools_get(f"/retail/supermarkets/{store_id}"))
@router.post("/api/retail/enrich")
async def api_retail_enrich(limit: int = Query(50, ge=1, le=200)):
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(f"{TOOLS}/retail/enrich", params={"limit": limit})
resp.raise_for_status()
return JSONResponse(resp.json())
+46
View File
@@ -0,0 +1,46 @@
/* Foodlinkk PWA — light cache for shell */
const CACHE = 'foodlinkk-v1';
const ASSETS = [
'/',
'/static/css/palantir-theme.css',
'/static/css/pulse-theme.css',
'/static/css/mobile.css',
'/static/css/vertical-tabs.css',
'/static/js/cockpit.js',
'/static/js/live-pulse.js',
'/static/icons/app-icon.svg',
'/static/manifest.json',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(ASSETS).catch(() => {}))
);
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
const url = new URL(e.request.url);
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
e.respondWith(
fetch(e.request)
.then((r) => {
if (r.ok && url.pathname.startsWith('/static/')) {
const clone = r.clone();
caches.open(CACHE).then((c) => c.put(e.request, clone));
}
return r;
})
.catch(() => caches.match(e.request).then((m) => m || caches.match('/')))
);
});
+192
View File
@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=5" />
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header">
<h1>Agents · Soul Registry</h1>
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<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'">
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
<span class="vtab-icon">🧬</span><span>Souls</span>
</button>
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
</button>
</nav>
<div class="vtabs-content">
<section x-show="tab==='souls'" x-cloak>
<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:#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">orchestrator</div></div></div><div class="hm-neo-kpi-value">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)">
<div class="hm-neo-kpi-top">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(s.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
<span class="agent-status-dot" x-show="s.is_active !== false"></span>
</div>
<div style="flex:1">
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
</div>
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
<div class="agent-meta-row">
<small x-text="(s.event_count||0) + ' events'"></small>
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small>
</div>
<div class="hm-eq" style="margin-top:0.5rem"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</article>
</template>
</div>
<div class="agent-soul-editor" x-show="selected" x-cloak>
<div class="agent-editor-header">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(selected?.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
</div>
<div style="flex:1">
<h2><span x-text="selected?.display_name"></span><span x-text="selected?.role_title"></span></h2>
<p style="margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text="selected?.responsibilities"></p>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0">
<button class="btn btn-pulse-green" @click="save()" :disabled="saving">Opslaan</button>
<a href="/settings?tab=permissions" class="btn btn-pulse-purple">Herman rechten →</a>
</div>
</div>
<label>Rol titel<input class="form-input" x-model="form.role_title"></label>
<label>Functie & werkzaamheden<textarea class="form-input" rows="3" x-model="form.responsibilities"></textarea></label>
<label>Soul.md (karakter, tone of voice, regels)<textarea x-model="form.soul_md"></textarea></label>
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-top:1rem">Recente activiteit</h3>
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
<div class="hub-feed-item"><span x-text="ev.created_at?.substring(0,16)"></span> <span x-text="ev.title||ev.event_type"></span></div>
</template>
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
</div>
</section>
<section x-show="tab==='mesh'" x-cloak>
<div class="panel">
<h2 style="margin-top:0;">Netwerk</h2>
<p class="page-tip" style="margin-top:.25rem;">Communication mesh met Herman als centrale Co-CEO hub.</p>
<div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
</div>
<div class="mesh-legend">
<span>Groen = healthy</span>
<span>Oranje = waarschuwing</span>
<span>Blauw = idle</span>
<span>Grijs = offline</span>
</div>
</div>
</section>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/agents-mesh.js"></script>
<script>
function agentsSoulHub() {
return {
tab: '{{ active_tab }}',
tabIndicatorTop: 0,
tabIndicatorH: 40,
meshLoaded: false,
souls: [], selected: null, form: {}, saving: false,
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; },
avatarStyle(key) {
const m = {
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob',
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl',
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short',
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses',
};
return m[key] || 'style-male-short';
},
hasHair(key) { return !!key; },
hasGlasses(key) { return ['herman','research','finance','legal'].includes(key); },
async init() {
await this.load();
this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn);
if (this.tab === 'mesh') this.mountMesh();
});
},
updateTabIndicator(el) {
if (!el || !this.$refs.vtabsNav) return;
const navRect = this.$refs.vtabsNav.getBoundingClientRect();
const btnRect = el.getBoundingClientRect();
this.tabIndicatorTop = Math.max(0, btnRect.top - navRect.top);
this.tabIndicatorH = btnRect.height || 40;
},
setTab(next, el) {
this.tab = next;
this.updateTabIndicator(el);
const url = new URL(window.location.href);
url.searchParams.set('tab', next);
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
},
mountMesh() {
if (this.meshLoaded || !window.AgentsMesh) return;
this.meshLoaded = true;
window.AgentsMesh.mount('agents-mesh-canvas');
},
async load() {
const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || [];
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
},
async select(key) {
const r = await fetch('/api/agents/souls/' + key).then(x => x.json());
this.selected = r.soul;
this.form = { role_title: r.soul.role_title, responsibilities: r.soul.responsibilities, soul_md: r.soul.soul_md };
},
async save() {
if (!this.selected) return;
this.saving = true;
try {
await Cockpit.api('/api/agents/souls/' + this.selected.agent_key, { method: 'PUT', body: JSON.stringify(this.form) });
Cockpit.toast('Soul opgeslagen', 'success');
await this.load();
await this.select(this.selected.agent_key);
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.saving = false;
}
};
}
</script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
{% 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>
<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>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=1"></script>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
from datetime import date, datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats
router = APIRouter(prefix="/api", tags=["api"])
def _stats_payload() -> dict[str, Any]:
stats: dict[str, Any] = {
"deals": 0,
"clients": 0,
"pending_approvals": 0,
"pipeline_value": 0,
}
try:
row = fetch_one("SELECT COUNT(*) AS c FROM deals")
stats["deals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM clients")
stats["clients"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
stats["pending_approvals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one(
"SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
)
stats["pipeline_value"] = float(row["total"]) if row else 0
except Exception:
pass
return stats
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
return {"ok": True, "stats": serialize_stats(collect_briefing_data()), "at": datetime.utcnow().isoformat()}
@router.post("/herman/briefing")
async def herman_briefing():
try:
content, stats = await generate_daily_briefing()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
@router.get("/herman/briefing/latest")
async def herman_briefing_latest():
try:
row = fetch_one(
"SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
live_stats = serialize_stats(collect_briefing_data())
if not row:
return {"ok": True, "content": None, "stats": live_stats}
if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
row["created_at"] = row["created_at"].isoformat()
return {"ok": True, **row, "stats": live_stats}
@router.get("/events")
async def list_events(limit: int = 50):
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
for row in rows:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
return {"events": rows}
+158
View File
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#0a0e14" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Foodlinkk" />
<title>{% block title %}{{ page_title }} · Foodlinkk{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json?v=1" />
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
<link rel="stylesheet" href="/static/css/palantir-theme.css" />
<link rel="stylesheet" href="/static/css/pulse-theme.css" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=3" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block extra_head %}{% endblock %}
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen }">
<div class="app-shell">
<aside class="app-sidebar" @click="if($event.target.closest('a')) navOpen=false">
<div class="app-sidebar-brand">
<p class="brand-title">Foodlinkk</p>
<p class="brand-name">Command Center</p>
</div>
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}">Herman</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Agents</div>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">System</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}">IT Ops</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
</div>
</aside>
<div class="mobile-nav-overlay" x-show="navOpen" @click="navOpen=false" x-cloak></div>
<div class="app-main">
<header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen"></button>
<span class="page-tip">Foodlinkk</span>
<div class="app-clock"><strong x-text="clockDate"></strong> · <span x-text="clockTime"></span></div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
</div>
</div>
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/cockpit.js"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script>
function appShell() {
return {
drawerOpen: false,
navOpen: false,
showInstall: false,
_deferredPrompt: null,
clockDate: '—',
clockTime: '—',
_offset: 0,
initApp() {
this.initClock();
this.initPwa();
},
async initClock() {
try {
const r = await fetch('/api/server-time').then(x => x.json());
this._offset = new Date(r.utc).getTime() - Date.now();
} catch (e) { this._offset = 0; }
const tick = () => {
const d = new Date(Date.now() + this._offset);
this.clockDate = d.toLocaleDateString('nl-NL', { timeZone: 'Europe/Amsterdam', weekday: 'short', day: 'numeric', month: 'short' });
this.clockTime = d.toLocaleTimeString('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
tick();
setInterval(tick, 1000);
},
initPwa() {
if (window.matchMedia('(display-mode: standalone)').matches) return;
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.showInstall = !localStorage.getItem('pwa-dismiss');
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this._deferredPrompt = e;
this.showInstall = true;
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js?v=1').catch(function () {});
}
},
async installPwa() {
if (this._deferredPrompt) {
this._deferredPrompt.prompt();
await this._deferredPrompt.userChoice;
this._deferredPrompt = null;
this.showInstall = false;
return;
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
} else {
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
}
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
}
};
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/beurs.css?v=1" />
{% endblock %}
{% block content %}
<div class="beurs-shell" x-data="beursHub()" x-init="init()">
<header class="beurs-hero">
<div>
<h1>Beurs & Live Intel <span class="live-badge">Live</span></h1>
<p class="hero-sub">Supermarkt aandelen · halal trends · concept lab · platform events</p>
<small x-text="'Laatste refresh: ' + (lastRefresh || '—')"></small>
</div>
<div class="beurs-hero-actions">
<button class="btn btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
<a href="/" class="btn">Herman Dashboard</a>
</div>
</header>
<div class="neo-tab-bar" role="tablist">
<button type="button" class="neo-tab" :class="{ active: tab === 'beurs' }" @click="setTab('beurs')">
<span class="neo-tab-icon">📈</span><span>Beurs Live</span>
</button>
<button type="button" class="neo-tab" :class="{ active: tab === 'trends' }" @click="setTab('trends')">
<span class="neo-tab-icon">☪️</span><span>Halal & Trends</span>
</button>
<button type="button" class="neo-tab" :class="{ active: tab === 'concepten' }" @click="setTab('concepten')">
<span class="neo-tab-icon">💡</span><span>Concept Lab</span>
</button>
<button type="button" class="neo-tab" :class="{ active: tab === 'events' }" @click="setTab('events')">
<span class="neo-tab-icon"></span><span>Platform Live</span>
<span class="neo-tab-badge" x-show="platformStats.pending_approvals" x-text="platformStats.pending_approvals"></span>
</button>
<div class="neo-tab-indicator" :style="indicatorStyle"></div>
</div>
<!-- BEURS LIVE -->
<section x-show="tab === 'beurs'" x-cloak class="beurs-section">
<div class="beurs-summary-row">
<div class="beurs-stat-card">
<span>Gem. dag %</span>
<strong :class="(summary.avg_change_pct||0) >= 0 ? 'up' : 'down'" x-text="((summary.avg_change_pct||0) >= 0 ? '+' : '') + (summary.avg_change_pct||0) + '%'"></strong>
</div>
<div class="beurs-stat-card">
<span>Beursgenoteerd</span>
<strong x-text="summary.listed_count || listed.length"></strong>
</div>
<div class="beurs-stat-card">
<span>NL privé ketens</span>
<strong x-text="summary.unlisted_nl_count || unlisted.length"></strong>
</div>
<div class="beurs-stat-card source-card">
<span>Databron</span>
<strong><a href="https://finance.yahoo.com" target="_blank" rel="noopener">Yahoo Finance ↗</a></strong>
</div>
</div>
<h2 class="section-label"><span class="hm-live-dot"></span> Beursgenoteerde supermarkten</h2>
<div class="beurs-grid">
<template x-for="s in listed" :key="s.symbol">
<article class="beurs-card" :class="'trend-' + (s.trend || 'flat')">
<div class="beurs-card-head">
<div class="beurs-chain-dot" :style="'background:' + (s.color || '#38bdf8')"></div>
<div>
<strong x-text="s.symbol"></strong>
<small x-text="s.name"></small>
</div>
<span class="beurs-pct" :class="(s.change_pct||0) >= 0 ? 'up' : 'down'"
x-text="((s.change_pct||0) >= 0 ? '▲' : '▼') + ' ' + Math.abs(s.change_pct||0).toFixed(2) + '%'"></span>
</div>
<div class="beurs-chains" x-text="(s.chains||[]).join(' · ')"></div>
<div class="beurs-price" x-show="s.price != null">
<span x-text="Number(s.price).toFixed(2)"></span>
<small x-text="s.currency"></small>
<span class="market-state" x-text="s.market_state"></span>
</div>
<svg class="beurs-spark" x-show="(s.sparkline||[]).length" viewBox="0 0 100 30" preserveAspectRatio="none">
<polyline fill="none" stroke-width="2" :stroke="(s.change_pct||0) >= 0 ? '#4ade80' : '#fb7185'" :points="sparkPoints(s.sparkline)"/>
</svg>
<div class="beurs-eq"><span></span><span></span><span></span><span></span><span></span><span></span></div>
<div class="beurs-sources">
<a class="btn-source" :href="s.source_url" target="_blank" rel="noopener">Yahoo Finance ↗</a>
<small x-text="s.exchange"></small>
</div>
</article>
</template>
</div>
<h2 class="section-label" style="margin-top:1.5rem">Nederlandse ketens · niet beursgenoteerd</h2>
<p class="hint">Jumbo, Plus, Dirk, Lidl en ALDI zijn geen beursgenoteerde entiteiten — geen live koers via Yahoo Finance.</p>
<div class="beurs-grid unlisted">
<template x-for="u in unlisted" :key="u.name">
<article class="beurs-card unlisted-card">
<div class="beurs-card-head">
<div class="beurs-chain-dot" :style="'background:' + (u.color || '#64748b')"></div>
<div>
<strong x-text="u.name"></strong>
<small x-text="u.parent"></small>
</div>
<span class="beurs-tag">Privé</span>
</div>
<div class="beurs-chains" x-text="(u.chains||[]).join(' · ')"></div>
<p class="unlisted-note" x-text="u.note || u.exchange"></p>
<div class="beurs-sources">
<a class="btn-source" :href="u.source_url" target="_blank" rel="noopener">Keten info ↗</a>
</div>
</article>
</template>
</div>
</section>
<!-- HALAL & TRENDS -->
<section x-show="tab === 'trends'" x-cloak class="beurs-section">
<div class="beurs-two-col">
<div class="panel-inner">
<h2>☪️ Halal vlees trends <span class="live-badge">RSS Live</span></h2>
<template x-for="h in trends.halal_meat_trends" :key="h.link">
<div class="feed-card">
<span class="tag tag-halal">HALAL VLEES</span>
<a :href="h.source_url || h.link" target="_blank" rel="noopener"><strong x-text="h.title"></strong></a>
<p x-text="(h.summary||'').substring(0,160)"></p>
<div class="feed-actions">
<a class="btn-source" :href="h.link" target="_blank">Artikel ↗</a>
<a class="btn-source" :href="h.feed_url" target="_blank">Bron feed ↗</a>
</div>
</div>
</template>
<p x-show="!trends.halal_meat_trends?.length" class="muted">Geen halal vlees trends — refresh RSS feeds.</p>
</div>
<div class="panel-inner">
<h2>🍱 Top gerechten & maaltijden</h2>
<template x-for="d in trends.top_dishes" :key="d.dish_keyword">
<div class="feed-card dish-card">
<div class="dish-rank">
<strong x-text="d.dish_keyword"></strong>
<span class="tag tag-dish" x-text="d.mentions + '× in feed'"></span>
</div>
<a x-show="d.latest_link" :href="d.source_url" target="_blank" rel="noopener">
<small x-text="d.latest_title"></small>
</a>
<div class="feed-actions" x-show="d.latest_link">
<a class="btn-source" :href="d.latest_link" target="_blank">Bron artikel ↗</a>
</div>
</div>
</template>
<h3 style="margin-top:1.25rem">Markt trends DB</h3>
<template x-for="t in trends.market_trends" :key="t.trend_name">
<div class="feed-card">
<strong x-text="t.trend_name"></strong>
<p x-text="t.description"></p>
<span class="tag tag-trend" x-text="'Kans ' + Math.round((t.opportunity_score||0)*100) + '%'"></span>
</div>
</template>
</div>
</div>
</section>
<!-- CONCEPT LAB -->
<section x-show="tab === 'concepten'" x-cloak class="beurs-section">
<div class="concept-header">
<h2>💡 Concept Lab</h2>
<p class="hint">Automatisch gegenereerde productconcepten op basis van live beurs, halal trends en top gerechten.</p>
<button class="btn btn-pulse-green" @click="loadConcepts()" :disabled="busy">↻ Nieuwe concepten</button>
</div>
<div class="concept-grid">
<template x-for="c in concepts" :key="c.id">
<article class="concept-card">
<span class="concept-num" x-text="'#' + c.id"></span>
<p x-text="c.concept"></p>
<div class="concept-based">
<small x-text="'Gebaseerd op: ' + (c.based_on?.dish || '') + ' · ' + (c.based_on?.market_signal || '')"></small>
</div>
<div class="feed-actions">
<template x-for="(url, i) in (c.source_urls||[])" :key="i">
<a class="btn-source" :href="url" target="_blank" rel="noopener">Bron <span x-text="i+1"></span></a>
</template>
</div>
</article>
</template>
</div>
</section>
<!-- PLATFORM LIVE -->
<section x-show="tab === 'events'" x-cloak class="beurs-section">
<div class="beurs-summary-row">
<div class="beurs-stat-card"><span>Events totaal</span><strong x-text="platformStats.total_events||0"></strong></div>
<div class="beurs-stat-card"><span>Laatste uur</span><strong x-text="platformStats.events_last_hour||0"></strong></div>
<div class="beurs-stat-card warn"><span>Wacht op OK</span><strong x-text="platformStats.pending_approvals||0"></strong></div>
</div>
<div class="events-toolbar">
<select class="form-input" x-model="eventFilter" @change="loadEvents()">
<option value="">Alle agents</option>
<option value="herman">Herman</option>
<option value="retail_intel">Retail Intel</option>
<option value="rss_feeds">RSS Feeds</option>
<option value="wholesale_scraper">Wholesale</option>
<option value="halal_registry">Halal</option>
<option value="hermes">Hermes</option>
</select>
<span class="live-badge">Auto-refresh 5s</span>
</div>
<div class="events-stream">
<template x-for="ev in events" :key="ev.id">
<a class="event-row" :href="ev.click_url" :target="ev.is_external ? '_blank' : '_self'" rel="noopener">
<span class="event-time" x-text="(ev.created_at||'').substring(11,16)"></span>
<span class="event-agent badge" x-text="ev.agent_name"></span>
<span class="event-title" x-text="ev.title"></span>
<span class="event-status" :class="'st-'+ev.status" x-text="ev.status"></span>
<span class="event-source" x-text="ev.is_external ? '↗ extern' : '→ platform'"></span>
</a>
</template>
<p x-show="!events.length" class="muted">Geen events gelogd.</p>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/beurs.js?v=1"></script>
{% endblock %}
+212
View File
@@ -0,0 +1,212 @@
window.BriefingCharts = (function () {
var charts = {};
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function parseContent(content) {
var parts = (content || '').split(/\n---\n/);
var ai = parts[0] || '';
var summary = '';
var actions = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|$)/i);
if (sm) summary = sm[1].trim();
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*)/i);
if (am) {
actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
}
if (!summary && ai.trim()) summary = ai.trim().slice(0, 600);
return { summary: summary, actions: actions };
}
function chartColors() {
return {
gold: 'rgba(232, 168, 56, 0.85)',
goldDim: 'rgba(232, 168, 56, 0.35)',
cyan: 'rgba(56, 189, 248, 0.85)',
green: 'rgba(62, 207, 142, 0.85)',
red: 'rgba(239, 68, 68, 0.85)',
gray: 'rgba(107, 114, 128, 0.85)',
grid: 'rgba(148, 163, 184, 0.12)',
text: '#94a3b8',
};
}
function renderPipeline(canvas, stats) {
if (!canvas) return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return r.stage || '?'; }),
datasets: [{
label: 'Pipeline EUR',
data: rows.map(function (r) { return Number(r.total) || 0; }),
backgroundColor: c.gold,
borderRadius: 6,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
scales: {
y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } },
x: { ticks: { color: c.text }, grid: { display: false } },
},
},
});
}
function renderSentiment(canvas, stats) {
if (!canvas) return;
var files = stats.nas_files || [];
var counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) {
var s = (f.sentiment_label || 'neutral').toLowerCase();
if (counts[s] !== undefined) counts[s]++;
});
if (!files.length) { counts.neutral = 1; }
var c = chartColors();
charts.sentiment = new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } },
},
});
}
function renderWords(canvas, stats) {
if (!canvas) return;
var rows = (stats.top_words || []).slice(0, 8);
var c = chartColors();
charts.words = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return r.lemma; }),
datasets: [{
label: 'Count',
data: rows.map(function (r) { return Number(r.total) || 0; }),
backgroundColor: c.cyan,
borderRadius: 6,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
scales: {
x: { ticks: { color: c.text }, grid: { color: c.grid } },
y: { ticks: { color: c.text }, grid: { display: false } },
},
},
});
}
function renderAgents(canvas, stats) {
if (!canvas) return;
var events = stats.recent_events || [];
var map = {};
events.forEach(function (e) {
var a = e.agent_name || 'other';
map[a] = (map[a] || 0) + 1;
});
var labels = Object.keys(map);
var values = labels.map(function (k) { return map[k]; });
var c = chartColors();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: {
labels: labels,
datasets: [{ data: values, backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray, c.goldDim] }],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } },
},
});
}
function renderKpis(container, stats) {
if (!container) return;
var items = [
{ label: 'Pipeline', value: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), cls: 'green' },
{ label: 'Klanten', value: stats.clients || 0, cls: 'purple' },
{ label: 'Deals', value: stats.deals || 0, cls: '' },
{ label: 'NAS docs', value: stats.nas_docs || 0, cls: 'cyan' },
{ label: 'Goedkeuringen', value: stats.pending_approvals || 0, cls: 'amber' },
];
container.innerHTML = items.map(function (it) {
return '<div class="briefing-stat ' + it.cls + '"><span class="briefing-stat-label">' + it.label + '</span><span class="briefing-stat-value">' + it.value + '</span></div>';
}).join('');
}
function renderNasList(container, stats) {
if (!container) return;
var files = stats.nas_files || [];
if (!files.length) { container.innerHTML = '<p class="empty-state">Geen NAS documenten.</p>'; return; }
container.innerHTML = '<table class="data-table"><thead><tr><th>Bestand</th><th>Type</th><th>Sentiment</th><th>Woorden</th></tr></thead><tbody>' +
files.map(function (f) {
var badge = 'badge-' + (f.sentiment_label || 'neutral');
return '<tr><td>' + (f.filename || '') + '</td><td>' + (f.doc_type || '') + '</td><td><span class="badge ' + badge + '">' + (f.sentiment_label || '') + '</span></td><td>' + (f.word_count || '—') + '</td></tr>';
}).join('') + '</tbody></table>';
}
function renderPending(container, stats) {
if (!container) return;
var items = stats.pending_items || [];
if (!items.length) { container.innerHTML = '<p class="empty-state">Geen openstaande goedkeuringen 🎉</p>'; return; }
container.innerHTML = items.map(function (it) {
return '<div class="briefing-pending-item"><span class="badge badge-agent-' + (it.agent_name || '').toLowerCase() + '">' + (it.agent_name || '') + '</span> ' + (it.title || it.event_type || '') + '</div>';
}).join('');
}
function render(root, stats, content, createdAt) {
if (!root || !stats) return;
destroyAll();
var parsed = parseContent(content);
root.style.display = 'block';
var empty = document.getElementById('briefing-empty');
if (empty) empty.style.display = 'none';
var meta = document.getElementById('briefing-meta');
if (meta) meta.textContent = createdAt ? ('Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ')) : '';
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl) sumEl.textContent = parsed.summary || (content ? 'Geen samenvatting.' : 'Klik op «Genereer dagrapport» voor een AI-samenvatting en actiepunten.');
var actEl = document.getElementById('briefing-actions-list');
if (actEl) {
actEl.innerHTML = parsed.actions.length
? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('')
: '<li>Geen actiepunten — genereer opnieuw.</li>';
}
renderKpis(document.getElementById('briefing-kpis'), stats);
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderWords(document.getElementById('chart-words'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
renderNasList(document.getElementById('briefing-nas-list'), stats);
renderPending(document.getElementById('briefing-pending-list'), stats);
var full = document.getElementById('briefing-full-text');
if (full) full.textContent = content || '';
}
return { render: render, destroyAll: destroyAll, parseContent: parseContent };
})();
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import ollama
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "", params: tuple = ()) -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}", params or None)
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def serialize_stats(data: dict[str, Any]) -> dict[str, Any]:
"""JSON-safe copy of briefing stats (datetimes, decimals)."""
def _default(o: Any) -> Any:
if hasattr(o, "isoformat"):
return o.isoformat()
if hasattr(o, "__float__"):
try:
return float(o)
except (TypeError, ValueError):
pass
return str(o)
return json.loads(json.dumps(data, default=_default))
def collect_briefing_data() -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
data["clients"] = _safe_count("clients")
data["deals"] = _safe_count("deals")
data["products"] = _safe_count("products")
data["suppliers"] = _safe_count("suppliers")
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
try:
data["deals_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["deals_by_stage"] = []
try:
data["recent_clients"] = fetch_all(
"SELECT name, stage, email, created_at FROM clients ORDER BY created_at DESC LIMIT 5"
)
except Exception:
data["recent_clients"] = []
try:
data["recent_events"] = fetch_all(
"""
SELECT agent_name, event_type, title, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 12
"""
)
except Exception:
data["recent_events"] = []
try:
data["pending_items"] = fetch_all(
"""
SELECT agent_name, title, event_type, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at DESC LIMIT 8
"""
)
except Exception:
data["pending_items"] = []
try:
row = fetch_one(
"""
SELECT COUNT(*) AS docs, COALESCE(SUM(word_count), 0) AS words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
FROM document_analytics
"""
)
data["nas_docs"] = int(row["docs"] or 0) if row else 0
data["nas_words"] = int(row["words"] or 0) if row else 0
data["nas_sentiment"] = round(float(row["avg_sentiment"] or 0), 3) if row else 0.0
except Exception:
data["nas_docs"] = data["nas_words"] = 0
data["nas_sentiment"] = 0.0
try:
data["nas_files"] = fetch_all(
"""
SELECT filename, doc_type, sentiment_label, word_count
FROM document_analytics ORDER BY analyzed_at DESC LIMIT 8
"""
)
except Exception:
data["nas_files"] = []
try:
data["top_words"] = fetch_all(
"""
SELECT lemma, SUM(count) AS total FROM document_word_counts
WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 10
"""
)
except Exception:
data["top_words"] = []
try:
data["calendar_events"] = fetch_all(
"""
SELECT ce.title, ce.starts_at, ce.ends_at, c.name AS client_name
FROM calendar_events ce
LEFT JOIN clients c ON c.id = ce.client_id
WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
AND ce.starts_at <= NOW() + INTERVAL '7 days'
ORDER BY ce.starts_at ASC LIMIT 10
"""
)
except Exception:
data["calendar_events"] = []
return data
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
"",
"## KPI's",
f"- **Klanten:** {data['clients']}",
f"- **Deals totaal:** {data['deals']}",
f"- **Pipeline (actief):** €{data['pipeline_eur']:,.0f}",
f"- **Producten:** {data['products']}",
f"- **Leveranciers:** {data['suppliers']}",
f"- **Openstaande goedkeuringen:** {data['pending_approvals']}",
"",
"## Pipeline per stage",
]
if data.get("deals_by_stage"):
for row in data["deals_by_stage"]:
lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
else:
lines.append("- Geen deals in database.")
lines.extend(["", "## Recente klanten"])
for row in data.get("recent_clients") or []:
lines.append(f"- {row.get('name')} ({row.get('stage')})")
if not data.get("recent_clients"):
lines.append("- Geen klanten.")
lines.extend([
"",
"## NAS share — documenten",
f"- Ingelezen documenten: **{data['nas_docs']}**",
f"- Totaal woorden geanalyseerd: **{data['nas_words']}**",
f"- Gemiddeld sentiment: **{data['nas_sentiment']}**",
"",
])
for row in data.get("nas_files") or []:
lines.append(f"- {row.get('filename')} · {row.get('doc_type')} · sentiment: {row.get('sentiment_label')}")
if data.get("top_words"):
lines.extend(["", "## Top woorden (NAS corpus)"])
for row in data["top_words"]:
lines.append(f"- {row.get('lemma')}: {row.get('total')}×")
if data.get("calendar_events"):
lines.extend(["", "## Agenda (7 dagen)"])
for row in data["calendar_events"]:
ts = row.get("starts_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
lines.extend(["", "## Recente agent activiteit"])
for row in data.get("recent_events") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] **{row.get('agent_name')}** — {row.get('title') or row.get('event_type')}")
if data.get("pending_items"):
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
return "\n".join(lines)
async def _ai_executive_summary(data: dict[str, Any], template: str) -> str:
prompt = (
"Schrijf alleen deze twee secties in het Nederlands (markdown):\n"
"## Samenvatting\n(4-6 zinnen voor CEO Aïssa)\n\n"
"## Actiepunten vandaag\n(minimaal 5 concrete bullets)\n\n"
f"Gebaseerd op:\n- Pipeline €{data['pipeline_eur']:,.0f}\n"
f"- {data['clients']} klanten, {data['deals']} deals\n"
f"- {data['pending_approvals']} goedkeuringen open\n"
f"- {data['nas_docs']} NAS documenten\n"
)
system = "Je bent Herman, co-CEO Foodlinkk. Kort, zakelijk, actionable."
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
def _save_briefing(content: str, data: dict[str, Any]) -> None:
safe = serialize_stats(data)
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
try:
execute(
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
(content, "herman", json.dumps(metadata)),
)
except Exception:
pass
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
"completed", "dashboard", json.dumps({"stats": safe}),
),
)
except Exception:
pass
async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data, template), timeout=90.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
if ai_part and len(ai_part.strip()) > 40:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = template + "\n\n---\n\n*AI-samenvatting niet beschikbaar (Ollama busy) — bovenstaande data is live uit je database.*"
_save_briefing(content, data)
return content, serialize_stats(data)
+561
View File
@@ -0,0 +1,561 @@
{% extends "base.html" %}
{% block extra_head %}
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
<style>
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.browser-tab {
padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
}
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
.browser-live-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;
}
@media (max-width: 900px) { .browser-live-row { grid-template-columns: 1fr; } }
.browser-live-panel { min-height: 380px; }
.browser-live-frame {
width: 100%; min-height: 420px; background: #0a0f1a; border-radius: 10px;
border: 1px solid rgba(148,163,184,.15); object-fit: contain;
}
.browser-novnc { width: 100%; height: 360px; border: 0; border-radius: 10px; background: #000; }
.browser-preview-text {
max-height: 220px; overflow: auto; font-size: 0.82rem; color: var(--text-muted);
white-space: pre-wrap; background: rgba(0,0,0,.25); padding: .75rem; border-radius: 8px;
}
.browser-links { max-height: 160px; overflow: auto; font-size: 0.78rem; }
.browser-status { font-size: 0.8rem; color: var(--accent-cyan); margin-top: 0.35rem; }
.novnc-hint { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.5rem; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
.preset-chip { font-size: 0.78rem; padding: 0.25rem 0.55rem; border-radius: 999px; cursor: pointer; border: 1px solid rgba(148,163,184,.2); background: rgba(0,0,0,.2); }
.preset-chip:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
.job-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.85rem; margin-bottom: 1rem; border-radius: 10px;
background: rgba(0,0,0,.25); border: 1px solid rgba(148,163,184,.12);
}
.job-bar.active { border-color: rgba(232,168,56,.35); }
</style>
{% endblock %}
{% block content %}
<div x-data="browserPage()" x-init="init()">
<header class="page-header">
<h1>Browser &amp; Monitor</h1>
<p class="subtitle page-tip">Voer een URL in — Playwright + live VNC desktop openen dezelfde pagina. Gebruik <strong>Volledige extractie</strong> voor gewicht, prijs en alle zichtbare data.</p>
</header>
<div class="job-bar" :class="{ active: jobRunning }">
<strong style="font-size:0.85rem">Achtergrondtaak</strong>
<span class="browser-status" style="margin:0" x-text="jobLabel || 'Geen actieve taak — je kunt vrij navigeren'"></span>
<button type="button" class="btn btn-sm btn-reject" x-show="jobRunning" @click="stopJob()">⏹ Stop</button>
<label style="font-size:0.78rem;color:var(--text-muted);margin-left:auto">
<input type="checkbox" x-model="vncSync" @change="startPoll()" /> Screenshot = VNC scherm
</label>
<label style="font-size:0.78rem;color:var(--text-muted)">
<input type="checkbox" x-model="livePoll" @change="startPoll()" /> Live poll
</label>
</div>
<section class="panel">
<h2>Snelle sites</h2>
<p class="page-tip">Klik om URL te laden. TikTok/Instagram vereisen soms inloggen via VNC.</p>
<div class="preset-grid">
<span class="preset-chip" @click="loadPreset('https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/')">Bidfood Mekkafood</span>
<span class="preset-chip" @click="loadPreset('https://www.sligro.nl/')">Sligro</span>
<span class="preset-chip" @click="loadPreset('https://www.tiktok.com/')">TikTok</span>
<span class="preset-chip" @click="loadPreset('https://www.instagram.com/')">Instagram</span>
<span class="preset-chip" @click="loadPreset('https://www.cheemafood.nl/')">Cheema</span>
</div>
<div class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
<input class="form-input" style="flex:2;min-width:200px" placeholder="Nieuwe site URL" x-model="newSiteUrl" />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="newSiteName" />
<button type="button" class="btn btn-secondary" @click="addSiteQuick()">+ Toevoegen aan monitor</button>
</div>
</section>
<div class="browser-tabs">
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
</div>
<div class="browser-live-row" style="margin-bottom:1rem">
<section class="panel browser-live-panel">
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
</section>
<section class="panel browser-live-panel">
<h2>Live desktop <small class="muted">noVNC</small>
<a href="{{ novnc_url }}" target="_blank" style="font-size:0.75rem;margin-left:0.5rem">Open apart ↗</a>
</h2>
<iframe class="browser-novnc" src="{{ novnc_url }}" title="Live VNC" data-clear-on-nav></iframe>
<p class="novnc-hint">Wachtwoord: <code>Foodlinkk2026</code>. Tip: open VNC ook <a href="{{ novnc_url }}" target="_blank">in apart venster</a> — dan geen navigatie-waarschuwing bij wisselen van pagina.</p>
</section>
</div>
<div x-show="tab === 'research'">
<section class="panel browser-instruct-box">
<h2>Interactief — leer Herman wat te klikken</h2>
<p class="page-tip">Beschrijf in het Nederlands wat de browser moet doen. Voorbeeld: <em>Accepteren cookies, scroll naar beneden, klik op Mekkafood filter</em></p>
<textarea class="form-input" x-model="instruction" rows="3" placeholder="Accepteren cookies → scroll 2× → klik op 'Snacks'"></textarea>
<div class="browser-action-bar">
<button type="button" class="btn btn-primary" @click="instructBrowse()" :disabled="loading">Uitvoeren + screenshot</button>
<button type="button" class="btn btn-primary" @click="extractFull()" :disabled="loading">🔬 Volledige extractie</button>
<button type="button" class="btn btn-secondary" @click="vncNavigate()" :disabled="loading">🖥 Open in VNC</button>
<button type="button" class="btn btn-secondary" @click="exportPhoto()" :disabled="!session">📥 Foto export</button>
<button type="button" class="btn btn-secondary" @click="runOcr()" :disabled="!session">🔍 OCR tekst</button>
<button type="button" class="btn btn-secondary" @click="savePhotoToDb()" :disabled="!session">💾 Foto → DB</button>
<a class="btn btn-secondary" href="{{ gradio_url }}" target="_blank" rel="noopener">🤖 Browser Use AI</a>
</div>
<div class="browser-steps-log" x-show="stepsLog.length" x-text="stepsLog.join(' · ')"></div>
<div x-show="ocrChips.length" style="margin-top:0.5rem">
<small class="muted">OCR woorden (compact)</small>
<div class="word-chip-cloud" style="max-height:72px;margin-top:0.35rem">
<template x-for="(w,i) in ocrChips" :key="i">
<span class="word-chip low" x-text="w"></span>
</template>
</div>
</div>
</section>
<section class="panel">
<form class="form-grid" @submit.prevent="browse()" style="grid-template-columns: 1fr 1fr 80px auto auto auto;">
<input class="form-input" x-model="url" placeholder="https://..." required />
<input class="form-input" x-model="task" placeholder="Taak (optioneel)" />
<input class="form-input" type="number" min="1" max="12" x-model.number="scrollPages" title="Scroll pagina's voor extractie" />
<button type="submit" class="btn btn-primary" :disabled="loading">
<span x-show="!loading">Browse + VNC</span><span x-show="loading">Bezig…</span>
</button>
<button type="button" class="btn btn-secondary" @click="addMonitor()">+ Monitor</button>
</form>
<div class="browser-status" x-text="statusText"></div>
</section>
<div class="browser-layout">
<div>
<section class="panel" x-show="extractedItems.length">
<h2>Geëxtraheerde data <small class="muted" x-text="'(' + extractedItems.length + ' items)'"></small></h2>
<table class="data-table extract-table">
<thead><tr><th>Regel / product</th><th>Gewicht</th><th>Prijs</th><th>Bron</th></tr></thead>
<tbody>
<template x-for="(it, idx) in extractedItems" :key="idx">
<tr>
<td x-text="it.raw"></td>
<td x-text="it.weight || '—'"></td>
<td x-text="it.price || '—'"></td>
<td x-text="it.source || '—'"></td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="panel" x-show="session">
<h2 x-text="session ? session.title : 'Resultaat'"></h2>
<p class="page-tip"><span x-text="session ? session.final_url : ''"></span></p>
<div class="browser-preview-text" x-text="session ? (session.content_preview || session.content_text || '') : ''"></div>
<div class="browser-links" x-show="session && session.links && session.links.length">
<strong>Links op pagina:</strong>
<template x-for="lnk in (session.links || []).slice(0,15)" :key="lnk.href">
<div><a :href="lnk.href" target="_blank" x-text="lnk.text || lnk.href"></a></div>
</template>
</div>
</section>
</div>
<aside>
<section class="panel">
<h2>Recente sessies</h2>
<template x-for="s in recent" :key="s.id">
<div class="clickable-row" style="padding:.4rem 0;border-bottom:1px solid rgba(148,163,184,.1)" @click="loadSession(s.id)">
<strong x-text="s.title || s.url"></strong><br/>
<small x-text="s.created_at ? s.created_at.substring(0,16).replace('T',' ') : ''"></small>
</div>
</template>
<p class="empty-state" x-show="!recent.length">Nog geen sessies.</p>
</section>
</aside>
</div>
</div>
<div x-show="tab === 'monitor'" x-cloak>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi"><div class="kpi-label">Active sites</div><div class="kpi-value">{{ stats.active_sites }}</div></div>
<div class="kpi-card amber clickable-kpi"><div class="kpi-label">Changes 24h</div><div class="kpi-value">{{ stats.changes_24h }}</div></div>
</div>
<section class="panel">
<h2>Website toevoegen</h2>
<form class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center" @submit.prevent="addSite">
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
<button class="btn btn-primary" type="submit">Toevoegen</button>
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
</form>
</section>
<section class="panel">
<h2>Gemonitorde websites</h2>
<table class="data-table"><thead><tr><th>Naam</th><th>URL</th><th>Laatst gecheckt</th><th>Acties</th></tr></thead>
<tbody>{% for s in sites %}
<tr>
<td>{{ s.name }}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
<td>{{ s.last_crawled or '—' }}</td>
<td class="btn-group">
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
</td></tr>{% else %}
<tr><td colspan="4" class="empty-state">Geen sites — voeg een URL toe of klik + Monitor bij Research.</td></tr>
{% endfor %}</tbody></table>
</section>
<div class="grid-2">
<section class="panel"><h2>Wijzigingen</h2>
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
</section>
<section class="panel"><h2>Crawl logs</h2>
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
</section>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function browserPage() {
return {
tab: window.location.hash === '#monitor' ? 'monitor' : 'research',
url: {{ default_url | tojson }},
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
scrollPages: 6,
newSiteUrl: '',
newSiteName: '',
extractedItems: [],
stepsLog: [],
ocrText: '',
ocrChips: [],
loading: false,
livePoll: true,
vncSync: true,
jobRunning: false,
jobLabel: '',
jobAbort: null,
session: null,
recent: [],
monitorForm: { url: '', name: '' },
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
statusText: 'Klaar — voer een URL in of klik Browse nu.',
pollTimer: null,
normalizeUrl(u) {
u = (u || '').trim();
if (!u) return u;
if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
return u;
},
siteNameFromUrl(u) {
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (e) { return u; }
},
setTab(t) {
this.tab = t;
history.replaceState(null, '', t === 'monitor' ? '/browser#monitor' : '/browser');
},
init() {
if (window.location.hash === '#monitor') this.tab = 'monitor';
window.addEventListener('hashchange', () => {
this.tab = window.location.hash === '#monitor' ? 'monitor' : 'research';
});
this.loadRecent();
this.startPoll();
},
startPoll() {
if (this.pollTimer) clearInterval(this.pollTimer);
if (!this.livePoll) return;
const base = this.vncSync
? '/api/admin/browser/vnc/screenshot.jpg?t='
: '/api/admin/browser/live/screenshot.jpg?t=';
this.pollTimer = setInterval(() => {
this.screenshotUrl = base + Date.now();
}, 1500);
this.screenshotUrl = base + Date.now();
},
startJob(label) {
if (this.jobAbort) this.jobAbort.abort();
this.jobAbort = new AbortController();
this.jobRunning = true;
this.jobLabel = label;
return this.jobAbort.signal;
},
endJob(msg) {
this.jobRunning = false;
this.jobLabel = msg || '';
this.jobAbort = null;
},
stopJob() {
if (this.jobAbort) {
this.jobAbort.abort();
Cockpit.toast('Taak geannuleerd — server kan nog kort doorlopen', 'info');
}
this.endJob('Gestopt door gebruiker');
this.loading = false;
},
async runJob(label, fn) {
const signal = this.startJob(label);
this.loading = true;
try {
return await fn(signal);
} catch (e) {
if (e.name === 'AbortError') {
this.endJob('Gestopt');
return null;
}
throw e;
} finally {
this.loading = false;
if (this.jobRunning) this.endJob('Klaar');
}
},
reloadPage() {
if (window.Cockpit && Cockpit.clearIframes) Cockpit.clearIframes();
setTimeout(() => location.reload(), 80);
},
async loadRecent() {
try {
const r = await Cockpit.api('/browser/sessions?limit=8');
this.recent = r.sessions || [];
} catch (e) {}
},
async instructBrowse() {
this.stepsLog = [];
this.statusText = 'Instructies uitvoeren…';
try {
const r = await this.runJob('Instructies uitvoeren…', (signal) =>
Cockpit.api('/browser/instruct', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.session = r.session;
this.stepsLog = (r.session.metadata && r.session.metadata.steps) || [];
this.statusText = 'Klaar — ' + this.stepsLog.length + ' stappen';
if (r.session.id) {
this.screenshotUrl = '/api/admin/browser/sessions/' + r.session.id + '/screenshot.jpg?t=' + Date.now();
this.vncSync = false;
}
await this.loadRecent();
Cockpit.toast('Instructies uitgevoerd', 'success');
} catch (e) { this.statusText = e.message; Cockpit.toast(e.message, 'error'); }
},
exportPhoto() {
if (!this.session || !this.session.id) return;
const a = document.createElement('a');
a.href = '/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg';
a.download = 'foodlinkk-browser-' + this.session.id + '.jpg';
a.click();
},
async runOcr() {
if (!this.session || !this.session.id) return;
try {
const r = await Cockpit.api('/browser/sessions/' + this.session.id + '/ocr');
this.ocrText = r.ocr_text || '';
const words = (this.ocrText.match(/[\wÀ-ÿ]{3,}/g) || []);
const seen = new Set();
this.ocrChips = [];
words.forEach(w => { const k = w.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.ocrChips.push(w); } });
if (this.ocrChips.length > 40) this.ocrChips = this.ocrChips.slice(0, 40);
Cockpit.toast('OCR klaar', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async browse() {
this.statusText = 'Browser + VNC starten op achtergrond…';
try {
const r = await this.runJob('Browse + VNC…', async (signal) => {
const vncP = Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 4 }),
signal,
}).catch(() => null);
const browseP = Cockpit.api('/browser/browse', {
method: 'POST',
body: JSON.stringify({ url: this.url, task: this.task, instruction: this.instruction, wait_seconds: 4 }),
signal,
});
const [res] = await Promise.all([browseP, vncP]);
return res;
});
if (!r) return;
this.session = r.session;
this.statusText = 'Klaar — sessie #' + r.session.id + ' (je kunt verder navigeren)';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast('Browse gestart — VNC + database', 'success');
} catch (e) {
this.statusText = 'Fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async vncNavigate() {
this.statusText = 'VNC opent URL…';
try {
const r = await this.runJob('VNC navigate…', (signal) =>
Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.statusText = 'VNC: ' + (r.title || r.final_url || this.url);
this.vncSync = true;
this.startPoll();
Cockpit.toast('URL geopend in live desktop', 'success');
} catch (e) {
this.statusText = 'VNC fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async extractFull() {
this.statusText = 'Extractie op achtergrond…';
this.extractedItems = [];
try {
const r = await this.runJob('Volledige extractie…', (signal) =>
Cockpit.api('/browser/extract-full', {
method: 'POST',
body: JSON.stringify({
url: this.url, instruction: this.instruction, wait_seconds: 5,
scroll_pages: this.scrollPages, also_vnc: true,
}),
signal,
})
);
if (!r) return;
this.session = r.session;
this.extractedItems = r.session.extracted_items || (r.session.metadata && r.session.metadata.extracted_items) || [];
this.statusText = 'Extractie klaar — ' + this.extractedItems.length + ' items';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast(this.extractedItems.length + ' items geëxtraheerd', 'success');
} catch (e) {
this.statusText = 'Extractie fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
loadPreset(u) {
this.url = u;
this.statusText = 'Preset geladen — klik Browse + VNC of Volledige extractie';
},
async addSiteQuick() {
const u = this.normalizeUrl(this.newSiteUrl || this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Site toegevoegd — tab Monitor', 'success');
this.newSiteUrl = '';
this.newSiteName = '';
this.setTab('monitor');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async savePhotoToDb() {
if (!this.session || !this.session.id) return;
try {
const img = await fetch('/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg');
const blob = await img.blob();
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(blob);
});
const r = await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({
image_b64: b64,
source: 'browser-session',
filename: 'session-' + this.session.id + '.jpg',
session_id: this.session.id,
}),
});
Cockpit.toast('Foto opgeslagen in DB #' + r.photo.id, 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async loadSession(id) {
try {
const r = await Cockpit.api('/browser/sessions/' + id);
this.session = r.session;
this.url = r.session.url || this.url;
this.extractedItems = (r.session.metadata && r.session.metadata.extracted_items) || r.session.extracted_items || [];
this.screenshotUrl = '/api/admin/browser/sessions/' + id + '/screenshot.jpg?t=' + Date.now();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addMonitor() {
const u = this.normalizeUrl(this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: (this.session && this.session.title) || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Toegevoegd aan Site Monitor', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addSite() {
const payload = { ...this.monitorForm, url: this.normalizeUrl(this.monitorForm.url) };
if (!payload.url) return Cockpit.toast('URL verplicht', 'error');
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
try {
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
Cockpit.toast('Site toegevoegd', 'success');
this.reloadPage();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async crawlAll() {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
},
async crawlOne(id) {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
},
async toggleSite(id) {
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
this.reloadPage();
},
async removeSite(id) {
if (!confirm('Website permanent verwijderen uit monitoring?')) return;
await Cockpit.api('/monitor/sites/' + id + '?hard=true', { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success'); this.reloadPage();
},
};
}
</script>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div x-data="clientsCrud()">
<header class="page-header"><h1>Clients</h1>
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
<button class="btn btn-primary" @click="openModal()">Add client</button>
<div class="kanban">
{% set stages = ['intake','discovery','proposal','active','churned'] %}
{% for st in stages %}
<div class="kanban-col"><h3>{{ st }}</h3>
{% for c in clients if c.stage == st %}
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
</div>
</div>
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsCrud() {
return {
modal: false, drawer: false, detail: '',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
async save() {
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
location.reload();
},
async setStage(id, stage, name, email, notes) {
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
Cockpit.toast('Stage updated', 'success'); location.reload();
}
};
}
</script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
window.Cockpit = (function () {
const API = '/api/admin';
function toast(message, type) {
type = type || 'info';
let root = document.getElementById('cockpit-toasts');
if (!root) {
root = document.createElement('div');
root.id = 'cockpit-toasts';
root.className = 'toast-container';
document.body.appendChild(root);
}
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.textContent = message;
root.appendChild(el);
setTimeout(function () { el.classList.add('toast-out'); setTimeout(function () { el.remove(); }, 300); }, 3500);
}
function clearEmbeddedIframes() {
document.querySelectorAll('iframe.browser-novnc, iframe[data-clear-on-nav]').forEach(function (f) {
try { f.src = 'about:blank'; } catch (e) {}
});
}
async function request(path, options) {
options = options || {};
const url = path.startsWith('http') || path.startsWith('/api/') ? path : API + path;
const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
const fetchOpts = Object.assign({}, options, { headers: headers });
if (options.signal) fetchOpts.signal = options.signal;
const res = await fetch(url, fetchOpts);
let data = null;
try { data = await res.json(); } catch (e) { data = null; }
if (!res.ok) {
const msg = (data && (data.detail || data.message)) || res.statusText;
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
}
return data;
}
function confirmDelete(message) {
return window.confirm(message || 'Delete this item?');
}
function openDrawer(id) {
document.body.classList.add('drawer-open');
var el = document.getElementById(id);
if (el) el.classList.add('open');
}
function closeDrawer(id) {
document.body.classList.remove('drawer-open');
if (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('open');
}
document.querySelectorAll('.drawer.open').forEach(function (d) { d.classList.remove('open'); });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
document.addEventListener('click', function (e) {
var link = e.target.closest('a[href]');
if (!link || link.target === '_blank' || link.hasAttribute('download')) return;
var href = link.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return;
if (href.indexOf('6080') !== -1 || href.indexOf('7788') !== -1) return;
if (href.charAt(0) === '/' || href.indexOf('http') === 0) clearEmbeddedIframes();
}, true);
return { toast: toast, api: request, confirmDelete: confirmDelete, openDrawer: openDrawer, closeDrawer: closeDrawer, clearIframes: clearEmbeddedIframes };
})();
+194
View File
@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=8" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero">
<h1>Herman · Command Center <span class="live-badge">Live</span></h1>
<p class="hero-sub">CEO overzicht · klanten · briefing · retail · alles op een rij</p>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
<div class="briefing-loading-overlay" x-show="loading" x-cloak>
<div class="loading-dots"><span></span><span></span><span></span></div>
<p x-text="loadingMsg"></p>
</div>
<header class="herman-briefing-header">
<div>
<h2>Dagelijkse briefing</h2>
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
<a href="/analytics" class="btn">Analytics</a>
</div>
</header>
<div id="briefing-dashboard" class="hm-dash-layout">
<section class="hm-dash-section hm-dash-full">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's</h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full">
<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 · geen RSS — zie Marketing Hub</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">
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p>
</div>
<div class="briefing-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">
<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>Top retail kansen</h4><small><a href="/retail">Retail 360 →</a></small></div>
<div id="briefing-retail"></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
<div id="briefing-milestones"></div>
</div>
</div>
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
<div id="briefing-food-highlights" class="hm-highlight-scroll"></div>
</div>
</section>
<section class="hm-dash-section hm-dash-full">
<h3 class="hm-section-title">Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-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>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div>
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></div>
</div>
<div class="hm-chart-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>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div>
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></div>
</div>
</div>
</section>
</div>
</section>
<div class="hub-grid hm-dash-feed">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Wacht op OK</h2>
{% for item in approvals[:8] %}
<div class="approval-item"><span class="badge">{{ item.agent_name }}</span> {{ item.title or item.event_type }}</div>
{% else %}<p class="empty-state">Geen goedkeuringen 🎉</p>{% endfor %}
</section>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/briefing-charts.js?v=8"></script>
<script>
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() {
return {
loading: false, loadingMsg: 'Herman werkt…',
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
_pollStop: null, _wsStop: null,
async init() {
await this.refreshLive(false);
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => this.refreshLive(false), 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini');
if (!el) return;
const active = stats.clients_active || 0, total = stats.clients_total || stats.clients || 0;
el.innerHTML = '<a href="/clients" class="beurs-mini-chip up"><strong>' + active + '</strong> actief</a>' +
'<a href="/clients" class="beurs-mini-chip"><strong>' + total + '</strong> totaal klanten</a>' +
'<a href="/retail" class="beurs-mini-chip"><strong>' + (stats.crm_partnerships||0) + '</strong> partnerships</a>' +
'<a href="/marketing?tab=reclame" class="beurs-mini-chip"><strong>' + (stats.promo_campaigns||0) + '</strong> promo\'s</a>';
},
async refreshLive(toast) {
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) {
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats);
this.renderClientsMini(r.stats);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
},
async generate() {
this.loading = true;
this.loadingMsg = 'Live data laden…';
await this.refreshLive(false);
this.loadingMsg = 'Herman schrijft dagrapport…';
try {
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
this.content = r.content || '';
this.createdAt = r.generated_at || new Date().toISOString();
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt);
this.renderClientsMini(r.stats || {});
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.loading = false;
}
};
}
</script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _safe_count(table: str, where: str = "") -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "") -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def _briefing_payload(briefing: dict | None) -> dict:
"""Always use live DB stats; briefing text may be cached."""
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
if not briefing:
return payload
payload["content"] = briefing.get("content")
payload["created_at"] = briefing.get("created_at")
return payload
@router.get("/")
async def dashboard(request: Request):
kpis = {
"deals_count": _safe_count("deals"),
"clients_count": _safe_count("clients"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
"browser_sessions_24h": 0,
"monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
"supermarkets_count": _safe_count("supermarkets"),
"crm_partnerships": _safe_count("client_supermarket_links", "partnership_status = 'active'"),
}
briefing = None
try:
briefing = fetch_one(
"SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
if briefing and briefing.get("created_at"):
briefing["created_at"] = briefing["created_at"].isoformat()
except Exception:
briefing = None
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
approvals: list = []
try:
approvals = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at ASC LIMIT 25
"""
)
for ev in approvals:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
approvals = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at,
LEFT(content_text, 300) AS preview
FROM browser_sessions
ORDER BY created_at DESC LIMIT 8
"""
)
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
monitor_sites: list = []
monitor_changes: list = []
try:
monitor_sites = fetch_all(
"""
SELECT id, name, url, last_title, last_crawled, is_active
FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
"""
)
for s in monitor_sites:
if s.get("last_crawled"):
s["last_crawled"] = s["last_crawled"].isoformat()
monitor_changes = fetch_all(
"""
SELECT pc.id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC LIMIT 10
"""
)
for c in monitor_changes:
if c.get("changed_at"):
c["changed_at"] = c["changed_at"].isoformat()
except Exception:
pass
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"page_title": "Herman · Command Center",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
"agent_feed": agent_feed,
"approvals": approvals,
"browser_sessions": browser_sessions,
"monitor_sites": monitor_sites,
"monitor_changes": monitor_changes,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block content %}
<div x-data="dealsCrud()">
<header class="page-header"><h1>Deals</h1><p class="subtitle page-tip">Pipeline deals with full CRUD — click row to edit.</p></header>
<button class="btn btn-primary" @click="openNew()">Add deal</button>
<table class="data-table"><thead><tr><th>Title</th><th>Client</th><th>Value</th><th>Stage</th><th></th></tr></thead>
<tbody>{% for d in deals %}
<tr class="clickable-row" @click="editRow({{ d.id }}, '{{ d.title|e }}', {{ d.value or 0 }}, '{{ d.stage|e }}')">
<td>{{ d.title }}</td><td>{{ d.client_name or '—' }}</td><td>€{{ d.value }}</td><td>{{ d.stage }}</td>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ d.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
<h3>Deal</h3><input class="form-input" x-model="form.title" /><input type="number" class="form-input" x-model="form.value" /><input class="form-input" x-model="form.stage" />
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button></div></div></div>
</div>
{% endblock %}
{% block scripts %}
<script>
function dealsCrud() {
return {
show: false, id: null, form: { title: '', value: 0, stage: 'lead', client_id: null },
openNew() { this.id = null; this.form = { title: '', value: 0, stage: 'lead', client_id: null }; this.show = true; },
editRow(id, title, value, stage) { this.id = id; this.form = { title, value, stage, client_id: null }; this.show = true; },
async save() {
const body = { ...this.form, value: parseFloat(this.form.value) };
if (this.id) await Cockpit.api('/deals/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/deals', { method: 'POST', body: JSON.stringify(body) });
location.reload();
},
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/deals/' + id, { method: 'DELETE' }); location.reload(); }
};
}
</script>
{% endblock %}
+383
View File
@@ -0,0 +1,383 @@
{% extends "base.html" %}
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
.doc-chart-panel { height: 140px; }
.doc-chart-panel canvas { max-height: 110px !important; }
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
.photo-card { border:1px solid rgba(148,163,184,.15); border-radius:10px; overflow:hidden; background:rgba(0,0,0,.2); }
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
.detection-wrap canvas { position:absolute; left:0; top:0; pointer-events:none; }
</style>
{% endblock %}
{% block content %}
<div x-data="documentsDash()" x-init="init()">
<header class="page-header">
<h1>Documents &amp; Foto's — interactief dashboard</h1>
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p>
</header>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi" @click="filterSentiment=''"><div class="kpi-label">Documenten</div><div class="kpi-value" x-text="filteredDocs.length"></div></div>
<div class="kpi-card purple clickable-kpi"><div class="kpi-label">Totaal woorden</div><div class="kpi-value">{{ summary.total_words }}</div></div>
<div class="kpi-card green clickable-kpi"><div class="kpi-label">Foto's in DB</div><div class="kpi-value" x-text="photos.length"></div></div>
<div class="kpi-card orange clickable-kpi"><div class="kpi-label">Gem. sentiment</div><div class="kpi-value">{{ summary.avg_sentiment }}</div></div>
</div>
<div class="doc-dash-filters panel">
<input type="search" class="form-input" style="min-width:180px" placeholder="Zoek bestand..." x-model="filterQuery" @input="applyFilters()" />
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
<option value="">Alle sentiment</option>
<option value="positive">Positief</option>
<option value="neutral">Neutraal</option>
<option value="negative">Negatief</option>
</select>
<select class="form-input" x-model="filterType" @change="applyFilters()">
<option value="">Alle types</option>
<option value="presentation">Presentatie (pptx)</option>
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
</select>
<label style="font-size:0.8rem;color:var(--text-muted)"><input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live refresh (15s)</label>
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">↻ Foto's</button>
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
</div>
<div class="grid-2" style="margin-top:1rem">
<section class="panel doc-chart-panel">
<h2>Top woorden <small class="muted">klik = filter</small></h2>
<canvas id="wordsChart" height="110"></canvas>
</section>
<section class="panel doc-chart-panel">
<h2>Sentiment <small class="muted">klik = filter</small></h2>
<canvas id="sentimentChart" height="110"></canvas>
</section>
</div>
<section class="panel" style="margin-top:1rem" x-data="wordSearch()" x-init="init()">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap">
<h2 style="margin:0">Woord zoeken</h2>
<div style="display:flex;gap:.5rem">
<input type="search" x-model="query" @keydown.enter="search()" placeholder="Zoek lemma..." class="form-input" style="min-width:220px" />
<label style="display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:var(--text-muted)">
<input type="checkbox" x-model="includeStopwords" @change="search()" /> stopwoorden
</label>
<button class="btn btn-primary" @click="search()">Zoek</button>
</div>
</div>
<table class="data-table" style="margin-top:1rem">
<thead><tr><th>Lemma</th><th>Token</th><th>Totaal</th><th>Documenten</th></tr></thead>
<tbody>
<template x-if="loading"><tr><td colspan="4">Laden...</td></tr></template>
<template x-for="row in results" :key="row.lemma">
<tr class="clickable-row" @click="$root.filterQuery=row.lemma; $root.applyFilters()">
<td x-text="row.lemma"></td>
<td x-text="row.token || row.sample_token || '—'"></td>
<td x-text="row.total_count"></td>
<td x-text="row.document_count || row.doc_count"></td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="panel" style="margin-top:1rem">
<h2>Documenten op NAS <span class="muted" x-text="'(' + filteredDocs.length + ')'"></span></h2>
<table class="data-table">
<thead>
<tr><th>Bestand</th><th>Pad</th><th>Type</th><th>Woorden</th><th>Sentiment</th><th>Score</th><th>Geanalyseerd</th></tr>
</thead>
<tbody>
<template x-for="doc in filteredDocs" :key="doc.storage_path">
<tr>
<td x-text="doc.filename"></td>
<td class="muted" style="font-size:0.75rem" x-text="doc.storage_path"></td>
<td x-text="doc.doc_type"></td>
<td x-text="doc.word_count"></td>
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
<td x-text="doc.sentiment_compound != null ? Number(doc.sentiment_compound).toFixed(3) : '—'"></td>
<td x-text="(doc.analyzed_at || doc.modified_at || '').substring(0,16)"></td>
</tr>
</template>
<tr x-show="!filteredDocs.length"><td colspan="7" class="empty-state">Geen documenten voor dit filter.</td></tr>
</tbody>
</table>
</section>
<section class="panel" style="margin-top:1rem">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem">
<h2 style="margin:0">Foto's — OCR &amp; detectie</h2>
<div style="display:flex;gap:0.5rem">
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:220px" />
<button class="btn btn-secondary btn-sm" @click="loadNasImages()">NAS share foto's</button>
</div>
</div>
<p class="page-tip">Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).</p>
<div class="photo-grid" style="margin-top:1rem">
<template x-for="p in photos" :key="p.id">
<div class="photo-card clickable-row" @click="openPhoto(p.id)">
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
<div class="photo-card-body">
<strong x-text="'#' + p.id + ' ' + (p.source || '')"></strong><br/>
<span x-text="(p.ocr_preview || '').substring(0,80)"></span><br/>
<small x-text="(p.box_count || 0) + ' boxes · ' + (p.item_count || 0) + ' items'"></small>
<button class="btn btn-sm btn-secondary" style="margin-top:0.35rem" @click.stop="exportPhoto(p.id)">📥 Export</button>
</div>
</div>
</template>
<p class="empty-state" x-show="!photos.length">Nog geen foto's — upload hier of stuur via Telegram.</p>
</div>
</section>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<div class="detection-wrap">
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
<canvas id="detectionCanvas"></canvas>
</div>
<pre class="browser-preview-text" style="margin-top:0.75rem;max-height:160px" x-text="activeOcr"></pre>
<table class="data-table" x-show="activeItems.length" style="margin-top:0.5rem;font-size:0.78rem">
<thead><tr><th>Geëxtraheerd</th><th>Gewicht</th><th>Prijs</th></tr></thead>
<tbody>
<template x-for="(it,i) in activeItems" :key="i">
<tr><td x-text="it.raw"></td><td x-text="it.weight||'—'"></td><td x-text="it.price||'—'"></td></tr>
</template>
</tbody>
</table>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
const topWords = {{ top_words | tojson }};
const summary = {{ summary | tojson }};
const allDocuments = {{ documents | tojson }};
let shareMerged = false;
let wordsChartInst = null;
let sentimentChartInst = null;
function documentsDash() {
return {
filterQuery: '',
filterSentiment: '',
filterType: '',
filteredDocs: allDocuments,
docTypes: [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))],
photos: [],
activePhoto: null,
activeOcr: '',
activeItems: [],
detections: [],
liveRefresh: false,
liveTimer: null,
init() {
this.applyFilters();
this.renderCharts();
this.loadPhotos();
this.refreshShare(false);
},
mergeShareItems(items) {
const byPath = new Map(allDocuments.map(d => [d.storage_path, d]));
(items || []).forEach(f => {
const path = f.path || f.storage_path;
if (!path) return;
if (byPath.has(path)) {
const ex = byPath.get(path);
if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
return;
}
const row = {
filename: f.filename || path.split('/').pop(),
storage_path: path,
doc_type: f.doc_type || 'general',
word_count: f.word_count || 0,
sentiment_label: f.sentiment_label || 'neutral',
sentiment_compound: f.sentiment_compound || 0,
analyzed_at: f.analyzed_at || '',
modified_at: f.modified_at || '',
from_share: !f.analyzed_at,
};
allDocuments.push(row);
byPath.set(path, row);
});
this.docTypes = [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))];
this.applyFilters();
},
async refreshShare(showToast) {
try {
if (showToast !== false) Cockpit.toast('Share scannen…', 'info');
await fetch('/api/admin/documents/trigger-scan', { method: 'POST' });
const r = await fetch('/api/admin/documents/share-files?limit=300');
const data = await r.json();
if (data.ok) {
this.mergeShareItems(data.items || []);
if (showToast !== false) Cockpit.toast((data.total || 0) + ' bestanden op share', 'success');
}
} catch (e) {
if (showToast !== false) Cockpit.toast('Share scan mislukt', 'error');
}
},
applyFilters() {
const q = this.filterQuery.toLowerCase();
this.filteredDocs = allDocuments.filter(d => {
if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
if (this.filterType && d.doc_type !== this.filterType) return false;
if (q && !(d.filename || '').toLowerCase().includes(q)) return false;
return true;
});
},
renderCharts() {
if (wordsChartInst) wordsChartInst.destroy();
if (sentimentChartInst) sentimentChartInst.destroy();
const dash = this;
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
type: 'bar',
data: {
labels: topWords.slice(0, 10).map(w => w.lemma),
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
backgroundColor: 'rgba(232,168,56,.75)', borderWidth: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
onClick: (_, els) => { if (els[0]) { dash.filterQuery = topWords[els[0].index].lemma; dash.applyFilters(); } },
scales: { y: { beginAtZero: true, ticks: { color: '#9aa4b2', font: { size: 9 } } },
x: { ticks: { color: '#9aa4b2', font: { size: 9 } } } }
}
});
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
type: 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#cbd5e1', font: { size: 9 } } } },
onClick: (_, els) => {
if (!els[0]) return;
dash.filterSentiment = ['positive','neutral','negative'][els[0].index];
dash.applyFilters();
}
}
});
},
async loadPhotos() {
try {
const r = await Cockpit.api('/photos?limit=40');
this.photos = r.photos || [];
} catch (e) {}
},
toggleLive() {
if (this.liveTimer) clearInterval(this.liveTimer);
if (this.liveRefresh) {
this.liveTimer = setInterval(() => {
this.loadPhotos();
this.refreshShare(false);
}, 15000);
}
},
async openPhoto(id) {
this.activePhoto = id;
try {
const r = await Cockpit.api('/photos/' + id + '/detections');
this.detections = r.detections || [];
this.activeItems = r.extracted_items || [];
this.activeOcr = r.ocr_text || '';
setTimeout(() => this.drawDetections(), 200);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
drawDetections() {
const img = document.getElementById('detectionImg');
const canvas = document.getElementById('detectionCanvas');
if (!img || !canvas || !this.detections.length) return;
canvas.width = img.clientWidth;
canvas.height = img.clientHeight;
const sx = img.clientWidth / (img.naturalWidth || 1);
const sy = img.clientHeight / (img.naturalHeight || 1);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#e8a838';
ctx.lineWidth = 1;
ctx.font = '9px sans-serif';
this.detections.slice(0, 150).forEach(b => {
ctx.strokeRect(b.x * sx, b.y * sy, b.w * sx, b.h * sy);
if (b.text && b.text.length < 12) ctx.fillText(b.text, b.x * sx, b.y * sy - 2);
});
},
exportPhoto(id) {
const a = document.createElement('a');
a.href = '/api/admin/photos/' + id + '/image.jpg';
a.download = 'foodlinkk-photo-' + id + '.jpg';
a.click();
},
async uploadPhoto(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(file);
});
try {
await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
});
Cockpit.toast('Foto geanalyseerd en opgeslagen', 'success');
await this.loadPhotos();
} catch (e) { Cockpit.toast(e.message, 'error'); }
ev.target.value = '';
},
async loadNasImages() {
try {
const r = await fetch('/api/admin/documents/nas-images?limit=10');
const data = await r.json();
if (!data.items || !data.items.length) return Cockpit.toast('Geen afbeeldingen in NAS share', 'info');
Cockpit.toast(data.items.length + ' NAS afbeeldingen gevonden — analyseer via upload of Telegram', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
function wordSearch() {
return {
query: '', includeStopwords: false, results: topWords.slice(0, 20), loading: false,
async search() {
this.loading = true;
try {
const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
if (this.query.trim()) params.set('q', this.query.trim());
const res = await fetch('/api/admin/documents/words?' + params);
this.results = (await res.json()).items || [];
} catch (e) { Cockpit.toast('Woord zoeken mislukt', 'error'); }
finally { this.loading = false; }
},
init() { this.search(); }
};
}
</script>
<style>
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
</style>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
/* Herman dashboard — Telegram-tab style motion */
@keyframes tg-ripple {
0% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.5); }
70% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 14px rgba(56, 189, 248, 0); }
100% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); }
}
@keyframes tg-orbit {
0% { transform: rotate(0deg) translateX(4px) rotate(0deg); }
100% { transform: rotate(360deg) translateX(4px) rotate(-360deg); }
}
@keyframes tg-gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes tg-breathe {
0%, 100% { opacity: 0.85; filter: brightness(1); }
50% { opacity: 1; filter: brightness(1.15); }
}
@keyframes spin-dots {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.herman-hero {
position: relative;
overflow: hidden;
}
.herman-hero::before {
content: "";
position: absolute;
top: -50%; left: -50%;
width: 200%; height: 200%;
background: radial-gradient(circle at 30% 40%, rgba(56,189,248,0.12) 0%, transparent 45%),
radial-gradient(circle at 70% 60%, rgba(192,132,252,0.1) 0%, transparent 40%);
animation: tg-gradient-flow 8s ease infinite;
background-size: 200% 200%;
pointer-events: none;
}
.herman-briefing.pulse-panel {
animation: tg-ripple 3s ease-in-out infinite;
}
.briefing-card, .briefing-chart-panel, .briefing-stat {
animation: tg-breathe 4s ease-in-out infinite;
}
.briefing-card:nth-child(2) { animation-delay: 0.5s; }
.briefing-card:nth-child(3) { animation-delay: 1s; }
.hub-kpi-row .kpi-card {
animation: tg-ripple 3.5s ease-in-out infinite;
}
.hub-kpi-row .kpi-card:nth-child(2) { animation-delay: 0.4s; }
.hub-kpi-row .kpi-card:nth-child(3) { animation-delay: 0.8s; }
.hub-kpi-row .kpi-card:nth-child(4) { animation-delay: 1.2s; }
.hub-kpi-row .kpi-card:nth-child(5) { animation-delay: 1.6s; }
.live-badge::before { animation: tg-orbit 2s linear infinite, pulse-dot 1.2s ease-in-out infinite; }
.briefing-loading-overlay {
position: absolute;
inset: 0;
background: rgba(10, 14, 20, 0.75);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 20;
border-radius: 12px;
}
.briefing-loading-overlay .loading-dots {
display: flex;
gap: 0.5rem;
}
.briefing-loading-overlay .loading-dots span {
width: 12px; height: 12px;
border-radius: 50%;
background: var(--pulse-blue, #38bdf8);
animation: spin-dots 1.2s ease-in-out infinite;
}
.briefing-loading-overlay .loading-dots span:nth-child(2) { animation-delay: 0.15s; }
.briefing-loading-overlay .loading-dots span:nth-child(3) { animation-delay: 0.3s; }
.briefing-loading-overlay p { color: var(--text-primary); font-size: 1rem; margin: 0; }
.herman-briefing { position: relative; }
.retail-highlight {
animation: fade-up 0.4s ease;
text-decoration: none;
color: inherit;
transition: transform 0.2s, background 0.2s;
padding: 0.4rem 0.25rem;
border-radius: 8px;
}
.retail-highlight:hover {
background: rgba(56,189,248,0.1);
transform: translateX(4px);
}
.feed-card { animation: tg-breathe 5s ease-in-out infinite; }
.count-up { display: inline-block; transition: color 0.3s; }
.section-fill:not(:empty) {
min-height: 2rem;
}
.briefing-card h3, .horizon-card h3 {
text-shadow: 0 0 20px rgba(56,189,248,0.25);
}
.topnav-herman.active, .topnav a.active {
animation: pulse-ring 2.5s infinite;
}

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