diff --git a/README.md b/README.md
index 43ec7e7..1eee8c3 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@ docker compose up -d
## Documentatie
+- **[Platform A–Z](docs/PLATFORM-AZ.md)** — volledige referentie (start hier)
+- **[Context voor Cursor](docs/CONTEXT.md)** — korte import op andere laptop
- [Architectuur](docs/ARCHITECTURE.md)
- [Deploy](docs/DEPLOY.md)
- [Apps & pagina's](docs/APPS.md)
@@ -31,6 +33,12 @@ docker compose up -d
- [Agents](docs/AGENTS.md)
- [CSS thema](docs/CSS-THEME.md)
+## Gitea & SysOps
+
+- Repository: http://10.4.7.18:3001/aissa/foodlinkk-command-center
+- **SysOps** pusht bij elke `deploy-all.sh` de volledige codebase naar `main`
+- SysOps-verslag: IT Ops tab + Herman dagbriefing
+
## Belangrijkste routes
- `/` — Herman dashboard
diff --git a/cockpit/app/main.py b/cockpit/app/main.py
index 691ee93..ca3fc16 100644
--- a/cockpit/app/main.py
+++ b/cockpit/app/main.py
@@ -36,6 +36,7 @@ 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
+from app.routes.projects_api import router as projects_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@@ -74,6 +75,7 @@ for r in (
settings_router,
agents_api_router,
marketing_api_router,
+ projects_api_router,
):
app.include_router(r)
diff --git a/cockpit/app/routes/admin_api.py b/cockpit/app/routes/admin_api.py
index 59bc07e..7e835dc 100644
--- a/cockpit/app/routes/admin_api.py
+++ b/cockpit/app/routes/admin_api.py
@@ -168,7 +168,15 @@ class DelegateBody(BaseModel):
@admin_router.get("/clients")
def list_clients():
return {"items": _serialize_rows(fetch_all(
- "SELECT * FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 500"
+ """SELECT c.*,
+ (SELECT COUNT(*) FROM client_supermarket_links l WHERE l.client_id = c.id)
+ + (SELECT COUNT(*) FROM supermarkets s WHERE s.client_id = c.id
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l2
+ WHERE l2.supermarket_id = s.id AND l2.client_id = c.id
+ )) AS store_count
+ FROM clients c
+ ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC LIMIT 500"""
))}
@@ -199,6 +207,79 @@ def delete_client(client_id: int):
return {"ok": True}
+@admin_router.get("/clients/stats")
+def clients_stats():
+ stats: dict[str, Any] = {}
+ try:
+ rows = fetch_all("SELECT stage, COUNT(*) AS n FROM clients GROUP BY stage")
+ stats["by_stage"] = {r["stage"]: int(r["n"]) for r in rows}
+ stats["total"] = sum(stats["by_stage"].values())
+ stats["active"] = stats["by_stage"].get("active", 0)
+ row = fetch_one("SELECT COALESCE(SUM(mrr_estimate),0) AS s FROM clients WHERE stage NOT IN ('churned')")
+ stats["mrr_total"] = float(row["s"] or 0) if row else 0
+ row = fetch_one(
+ """SELECT COALESCE(SUM(d.value),0) AS s FROM deals d
+ JOIN clients c ON c.id = d.client_id
+ WHERE d.stage NOT IN ('won','lost')"""
+ )
+ stats["pipeline_value"] = float(row["s"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM client_supermarket_links")
+ stats["store_links"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM deals")
+ stats["deals"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "mrr_total": 0, "pipeline_value": 0, "store_links": 0, "deals": 0, "by_stage": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/clients/{client_id}/detail")
+def client_detail(client_id: int):
+ client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
+ if not client:
+ raise HTTPException(404, "Client not found")
+ deals = fetch_all(
+ """SELECT id, title, value, stage, next_action, deadline, updated_at
+ FROM deals WHERE client_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 20""",
+ (client_id,),
+ )
+ stores = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
+ FROM client_supermarket_links l
+ JOIN supermarkets s ON s.id = l.supermarket_id
+ WHERE l.client_id = %s
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ direct = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ 'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
+ FROM supermarkets s
+ WHERE s.client_id = %s
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l
+ WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
+ )
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ merged: dict[int, dict] = {}
+ for row in list(stores) + list(direct):
+ merged[int(row["id"])] = row
+ return {
+ "ok": True,
+ "client": _serialize(client),
+ "deals": _serialize_rows(deals),
+ "stores": _serialize_rows(list(merged.values())),
+ }
+
+
# --- Deals CRUD ---
@admin_router.get("/deals")
@@ -239,6 +320,36 @@ def delete_deal(deal_id: int):
# --- Products CRUD ---
+@admin_router.get("/products/stats")
+def products_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM products")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ rows = fetch_all("SELECT status, COUNT(*) AS n FROM products GROUP BY status")
+ stats["by_status"] = {r["status"]: int(r["n"]) for r in rows}
+ stats["active"] = stats["by_status"].get("active", 0)
+ row = fetch_one("SELECT AVG(margin_pct) AS a FROM products WHERE margin_pct IS NOT NULL")
+ stats["avg_margin"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM products WHERE client_id IS NOT NULL")
+ stats["with_client"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "avg_margin": 0, "with_client": 0, "by_status": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/products/{product_id}/detail")
+def product_detail(product_id: int):
+ product = fetch_one(
+ """SELECT p.*, c.name AS client_name, c.email AS client_email, c.stage AS client_stage
+ FROM products p LEFT JOIN clients c ON c.id = p.client_id WHERE p.id = %s""",
+ (product_id,),
+ )
+ if not product:
+ raise HTTPException(404, "Product not found")
+ return {"ok": True, "product": _serialize(product)}
+
+
@admin_router.get("/products")
def list_products():
return {"items": _serialize_rows(fetch_all(
@@ -284,6 +395,35 @@ def margin_calc(body: MarginBody):
# --- Suppliers CRUD ---
+@admin_router.get("/suppliers/stats")
+def suppliers_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM suppliers")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(rating) AS a FROM suppliers WHERE rating IS NOT NULL")
+ stats["avg_rating"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(DISTINCT country) AS n FROM suppliers WHERE country IS NOT NULL AND country <> ''")
+ stats["countries"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(lead_time_days) AS a FROM suppliers WHERE lead_time_days IS NOT NULL")
+ stats["avg_lead"] = round(float(row["a"] or 0), 0) if row else 0
+ rows = fetch_all(
+ "SELECT COALESCE(category, 'Overig') AS category, COUNT(*) AS n FROM suppliers GROUP BY category ORDER BY n DESC LIMIT 8"
+ )
+ stats["by_category"] = {r["category"]: int(r["n"]) for r in rows}
+ except Exception:
+ stats = {"total": 0, "avg_rating": 0, "countries": 0, "avg_lead": 0, "by_category": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/suppliers/{supplier_id}/detail")
+def supplier_detail(supplier_id: int):
+ supplier = fetch_one("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
+ if not supplier:
+ raise HTTPException(404, "Supplier not found")
+ return {"ok": True, "supplier": _serialize(supplier)}
+
+
@admin_router.get("/suppliers")
def list_suppliers():
return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))}
diff --git a/cockpit/app/routes/agents.py b/cockpit/app/routes/agents.py
index 08310ce..131bffe 100644
--- a/cockpit/app/routes/agents.py
+++ b/cockpit/app/routes/agents.py
@@ -12,7 +12,7 @@ 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": "Herman", "role": "Co-CEO · Takenverdeler", "color": "cyan"},
{"name": "Sales", "role": "Pipeline", "color": "purple"},
{"name": "Marketing", "role": "Social & Content", "color": "amber"},
{"name": "Ops", "role": "Operations", "color": "green"},
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
- if active_tab not in {"souls", "mesh"}:
+ if active_tab not in {"souls", "mesh", "approvals"}:
active_tab = "souls"
events: list = []
try:
diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py
index 433a41b..bd2efab 100644
--- a/cockpit/app/routes/agents_api.py
+++ b/cockpit/app/routes/agents_api.py
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import fetch_all
-from app.services import agent_souls
+from app.services import agent_approvals, agent_souls
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
@@ -20,6 +20,21 @@ class SoulUpdate(BaseModel):
is_active: Optional[bool] = None
+class ActionRequestBody(BaseModel):
+ agent_key: str
+ title: str = Field(..., min_length=1, max_length=255)
+ action_type: str = "query"
+ query_payload: dict[str, Any] = Field(default_factory=dict)
+
+
+class RejectBody(BaseModel):
+ reason: str = ""
+
+
+class ExecuteBody(BaseModel):
+ result: dict[str, Any] = Field(default_factory=dict)
+
+
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@@ -33,6 +48,15 @@ def api_get_soul(agent_key: str) -> dict[str, Any]:
return {"soul": soul}
+@router.get("/souls/{agent_key}/events")
+def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
+ soul = agent_souls.get_soul(agent_key)
+ if not soul:
+ raise HTTPException(404, "Agent not found")
+ items = agent_souls.list_agent_events(agent_key, limit=min(limit, 100))
+ return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
+
+
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
@@ -92,4 +116,111 @@ def api_agents_mesh() -> dict[str, Any]:
"""
)
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
- return {"nodes": nodes, "edges": edges}
+ return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
+
+
+@router.get("/approvals")
+def api_list_approvals(status: Optional[str] = None, limit: int = 50) -> dict[str, Any]:
+ try:
+ items = agent_approvals.list_requests(status=status, limit=limit)
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"items": items, "count": len(items)}
+
+
+@router.post("/requests")
+def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
+ try:
+ req = agent_approvals.create_request(
+ body.agent_key,
+ body.title,
+ action_type=body.action_type,
+ query_payload=body.query_payload,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
+
+
+@router.post("/approvals/{request_id}/approve")
+def api_approve_request(request_id: int) -> dict[str, Any]:
+ import os
+
+ import httpx
+
+ try:
+ req = agent_approvals.approve_request(request_id, approved_by="ceo")
+ action = req.get("action_type") or ""
+ auto_result = None
+
+ if action in ("config_backup", "maintenance_scan"):
+ tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+ path = "/ops/backup/run" if action == "config_backup" else "/ops/maintenance/scan"
+ payload = {"approval_request_id": request_id} if action == "config_backup" else None
+ with httpx.Client(timeout=180.0) as client:
+ resp = client.post(f"{tools_url}{path}", json=payload or {})
+ try:
+ auto_result = resp.json()
+ except Exception:
+ auto_result = {"ok": False, "detail": resp.text}
+ req = agent_approvals.mark_executed(request_id, auto_result or {})
+
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, "request": req, "executed": auto_result is not None, "result": auto_result}
+
+
+@router.post("/approvals/{request_id}/reject")
+def api_reject_request(request_id: int, body: RejectBody) -> dict[str, Any]:
+ try:
+ req = agent_approvals.reject_request(request_id, reason=body.reason)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ return {"ok": True, "request": req}
+
+
+@router.post("/approvals/{request_id}/execute")
+def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str, Any]:
+ import os
+
+ import httpx
+
+ try:
+ req = agent_approvals.require_approved(request_id)
+ result_payload = body.result or {}
+ action = req.get("action_type") or ""
+
+ if action == "config_backup":
+ tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+ with httpx.Client(timeout=180.0) as client:
+ resp = client.post(
+ f"{tools_url}/ops/backup/run",
+ json={"approval_request_id": request_id},
+ )
+ try:
+ result_payload = resp.json()
+ except Exception:
+ result_payload = {"ok": False, "detail": resp.text}
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail=result_payload.get("detail") or resp.text)
+
+ elif action == "maintenance_scan":
+ tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+ with httpx.Client(timeout=120.0) as client:
+ resp = client.post(f"{tools_url}/ops/maintenance/scan")
+ result_payload = resp.json() if resp.status_code < 400 else {"ok": False, "detail": resp.text}
+
+ req = agent_approvals.mark_executed(request_id, result_payload)
+ except HTTPException:
+ raise
+ except PermissionError as exc:
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, "request": req}
diff --git a/cockpit/app/routes/marketing_api.py b/cockpit/app/routes/marketing_api.py
index 7141ccb..4b6dea0 100644
--- a/cockpit/app/routes/marketing_api.py
+++ b/cockpit/app/routes/marketing_api.py
@@ -35,6 +35,7 @@ class PublishRequest(BaseModel):
image_url: str | None = None
media_ids: list[int] = Field(default_factory=list)
channels: list[str] = Field(default_factory=list)
+ project_id: int | None = None
@router.post("/upload")
@@ -91,7 +92,9 @@ def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks)
),
)
job_id = int(row["id"])
- background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids)
+ background_tasks.add_task(
+ run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids, body.project_id,
+ )
return {"job_id": job_id, "status": "queued", "channels": channels}
diff --git a/cockpit/app/routes/packaging.py b/cockpit/app/routes/packaging.py
index 3c2fd60..e243c21 100644
--- a/cockpit/app/routes/packaging.py
+++ b/cockpit/app/routes/packaging.py
@@ -1,18 +1,21 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any, Optional
import httpx
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.config import settings
+from app.services import packaging_nas, projects
router = APIRouter(tags=["packaging"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+TOOLS = settings.TOOLS_API_URL.rstrip("/")
class PackagingGenerateBody(BaseModel):
@@ -20,68 +23,226 @@ class PackagingGenerateBody(BaseModel):
width_mm: float = Field(default=120, gt=0)
height_mm: float = Field(default=80, gt=0)
depth_mm: float = Field(default=40, ge=0)
+ bleed_mm: float = Field(default=3, 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)
+ text: dict[str, Any] = Field(default_factory=dict)
+ barcode_value: str | None = None
+ qr_value: str | None = None
+ design_name: str | None = None
+ project_id: int | None = None
+ project_name: str | None = None
+ client_id: int | None = None
+ created_by: str = "ceo"
+
+
+class PackagingEmailBody(BaseModel):
+ to: list[str] = Field(..., min_length=1)
+ subject: str = Field(..., min_length=1)
+ body: str = ""
+ cc: list[str] = Field(default_factory=list)
+ client_id: int | None = None
+
+
+class CopyToProjectBody(BaseModel):
+ target_project_id: int
+
+
+async def _tools_request(method: str, path: str, **kwargs) -> httpx.Response:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.request(method, f"{TOOLS}{path}", **kwargs)
+ return r
+
+
+def _tools_error(exc: httpx.HTTPStatusError) -> HTTPException:
+ detail = exc.response.text[:500] if exc.response else str(exc)
+ return HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail)
+
+
+async def _ensure_project(body: PackagingGenerateBody) -> PackagingGenerateBody:
+ if body.project_id or not body.project_name:
+ return body
+ proj = projects.create_project(
+ body.project_name.strip(),
+ client_id=body.client_id,
+ description="Packaging design project",
+ project_type="packaging",
+ ensure_nas=True,
+ )
+ data = body.model_dump()
+ data["project_id"] = proj.get("id")
+ return PackagingGenerateBody(**data)
+
+
+async def _post_nas_export(result: dict[str, Any]) -> dict[str, Any]:
+ pid = result.get("cockpit_project_id")
+ packaging_id = result.get("id")
+ svg = result.get("svg") or ""
+ spec = result.get("spec") or {}
+ if not pid or not packaging_id or not svg:
+ return result
+ png_bytes = pdf_bytes = None
+ try:
+ png_r = await _tools_request("GET", f"/packaging/download/{packaging_id}", params={"format": "png"})
+ if png_r.status_code < 400:
+ png_bytes = png_r.content
+ pdf_r = await _tools_request("GET", f"/packaging/download/{packaging_id}", params={"format": "pdf"})
+ if pdf_r.status_code < 400:
+ pdf_bytes = pdf_r.content
+ except Exception:
+ pass
+ try:
+ nas_info = packaging_nas.export_packaging_files(packaging_id, int(pid), svg, spec, png_bytes, pdf_bytes)
+ result["nas"] = nas_info
+ except Exception as exc:
+ result["nas"] = {"ok": False, "error": str(exc)}
+ return result
@router.get("/packaging")
-async def packaging_page(request: Request):
+async def packaging_page(request: Request, project_id: Optional[int] = None):
return templates.TemplateResponse(
"packaging.html",
- {"request": request, "page_title": "Packaging Studio"},
+ {"request": request, "page_title": "Packaging Studio", "initial_project_id": project_id},
)
+@router.get("/api/packaging/types")
+async def proxy_packaging_types():
+ try:
+ r = await _tools_request("GET", "/packaging/types")
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.post("/api/packaging/preview")
+async def proxy_packaging_preview(body: PackagingGenerateBody):
+ try:
+ r = await _tools_request("POST", "/packaging/preview", json=body.model_dump())
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
@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()
+ body = await _ensure_project(body)
+ r = await _tools_request("POST", "/packaging/generate", json=body.model_dump())
+ r.raise_for_status()
+ result = r.json()
+ return await _post_nas_export(result)
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
+ raise _tools_error(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()
+ r = await _tools_request("GET", "/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
+ raise _tools_error(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)
+ r = await _tools_request("GET", f"/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
+ raise _tools_error(exc) from exc
+
+
+@router.get("/api/packaging/{packaging_id}")
+async def proxy_packaging_get(packaging_id: str):
+ try:
+ r = await _tools_request("GET", f"/packaging/{packaging_id}")
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.patch("/api/packaging/{packaging_id}")
+async def proxy_packaging_update(packaging_id: str, body: PackagingGenerateBody):
+ try:
+ r = await _tools_request("PATCH", f"/packaging/{packaging_id}", json={"spec": body.model_dump()})
+ r.raise_for_status()
+ result = r.json()
+ return await _post_nas_export(result)
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.post("/api/packaging/{packaging_id}/duplicate")
+async def proxy_packaging_duplicate(packaging_id: str):
+ try:
+ r = await _tools_request("POST", f"/packaging/{packaging_id}/duplicate")
+ r.raise_for_status()
+ result = r.json()
+ return await _post_nas_export(result)
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.post("/api/packaging/{packaging_id}/copy-to-project")
+async def proxy_packaging_copy(packaging_id: str, body: CopyToProjectBody):
+ try:
+ r = await _tools_request(
+ "POST",
+ f"/packaging/{packaging_id}/copy-to-project",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ result = r.json()
+ return await _post_nas_export(result)
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.delete("/api/packaging/{packaging_id}")
+async def proxy_packaging_delete(packaging_id: str):
+ try:
+ r = await _tools_request("DELETE", f"/packaging/{packaging_id}")
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
+
+
+@router.post("/api/packaging/{packaging_id}/email")
+async def proxy_packaging_email(packaging_id: str, body: PackagingEmailBody):
+ base = settings.COCKPIT_PUBLIC_URL if hasattr(settings, "COCKPIT_PUBLIC_URL") else "http://10.4.7.18:8600"
+ links = "\n".join(
+ f"- {fmt.upper()}: {base}/api/packaging/download/{packaging_id}?format={fmt}"
+ for fmt in ("svg", "png", "pdf")
+ )
+ email_body = (body.body or "").strip()
+ if email_body:
+ email_body += "\n\n"
+ email_body += f"Packaging downloads:\n{links}"
+ try:
+ r = await _tools_request(
+ "POST",
+ "/emails/send",
+ json={
+ "to": body.to,
+ "cc": body.cc,
+ "subject": body.subject,
+ "body": email_body,
+ "client_id": body.client_id,
+ },
+ )
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ raise _tools_error(exc) from exc
diff --git a/cockpit/app/routes/projects_api.py b/cockpit/app/routes/projects_api.py
new file mode 100644
index 0000000..3a7e5b2
--- /dev/null
+++ b/cockpit/app/routes/projects_api.py
@@ -0,0 +1,197 @@
+"""Projects + UI preferences API."""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from app.services import projects, ui_preferences, nas_folders
+
+router = APIRouter(tags=["projects-api"])
+
+
+class ProjectCreate(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ client_id: Optional[int] = None
+ description: str = ""
+ created_by: str = "ceo"
+ project_type: str = "general"
+ priority: str = "normal"
+
+
+class ProjectUpdate(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+ client_id: Optional[int] = None
+ project_type: Optional[str] = None
+ priority: Optional[str] = None
+
+
+class AssetCreate(BaseModel):
+ asset_type: str
+ title: str
+ ref_id: Optional[str] = None
+ file_path: Optional[str] = None
+ payload: dict[str, Any] = Field(default_factory=dict)
+ created_by: str = "ceo"
+ source_agent: Optional[str] = None
+
+
+class LinkPhotoBody(BaseModel):
+ project_id: int
+ created_by: str = "ceo"
+
+
+class LinkNasFileBody(BaseModel):
+ title: str
+ file_path: str
+ asset_type: str = "document"
+
+
+class PreferencesBody(BaseModel):
+ dashboard_layout: Optional[list[str]] = None
+ global_viz_mode: Optional[str] = None
+ viz_modes: Optional[dict[str, str]] = None
+ locale: Optional[str] = None
+
+
+@router.get("/projects")
+async def projects_page(request: Request):
+ from fastapi.templating import Jinja2Templates
+ from pathlib import Path
+ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent.parent / "templates"))
+ return templates.TemplateResponse("projects.html", {"request": request, "page_title": "Projecten"})
+
+
+@router.get("/api/projects/types")
+def api_project_types() -> dict[str, Any]:
+ return {"items": nas_folders.list_types()}
+
+
+@router.get("/api/projects/stats")
+def api_project_stats() -> dict[str, Any]:
+ return {"ok": True, "stats": projects.project_stats()}
+
+
+@router.get("/api/projects")
+def api_list_projects(client_id: Optional[int] = None, limit: int = 50) -> dict[str, Any]:
+ items = projects.list_projects(client_id=client_id, limit=limit)
+ return {"items": items, "count": len(items)}
+
+
+@router.post("/api/projects")
+def api_create_project(body: ProjectCreate) -> dict[str, Any]:
+ try:
+ row = projects.create_project(
+ body.name,
+ client_id=body.client_id,
+ description=body.description,
+ created_by=body.created_by,
+ project_type=body.project_type,
+ priority=body.priority,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, "project": row}
+
+
+@router.get("/api/projects/{project_id}")
+def api_get_project(project_id: int) -> dict[str, Any]:
+ row = projects.get_project(project_id)
+ if not row:
+ raise HTTPException(status_code=404, detail="Project not found")
+ return {"project": row}
+
+
+@router.patch("/api/projects/{project_id}")
+def api_update_project(project_id: int, body: ProjectUpdate) -> dict[str, Any]:
+ row = projects.update_project(project_id, **body.model_dump(exclude_none=True))
+ if not row:
+ raise HTTPException(status_code=404, detail="Project not found")
+ return {"ok": True, "project": row}
+
+
+@router.post("/api/projects/{project_id}/ensure-nas")
+def api_ensure_nas(project_id: int) -> dict[str, Any]:
+ row = projects.get_project(project_id)
+ if not row:
+ raise HTTPException(status_code=404, detail="Project not found")
+ client_name = row.get("client_name")
+ try:
+ paths = nas_folders.ensure_project_folder(
+ project_id,
+ row["name"],
+ row.get("client_id"),
+ client_name,
+ row.get("project_type") or "general",
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, **paths}
+
+
+@router.post("/api/projects/{project_id}/assets")
+def api_add_asset(project_id: int, body: AssetCreate) -> dict[str, Any]:
+ if not projects.get_project(project_id):
+ raise HTTPException(status_code=404, detail="Project not found")
+ try:
+ asset = projects.add_asset(
+ project_id,
+ body.asset_type,
+ body.title,
+ ref_id=body.ref_id,
+ file_path=body.file_path,
+ payload=body.payload,
+ created_by=body.created_by,
+ source_agent=body.source_agent,
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return {"ok": True, "asset": asset}
+
+
+@router.post("/api/projects/{project_id}/link-nas-file")
+def api_link_nas_file(project_id: int, body: LinkNasFileBody) -> dict[str, Any]:
+ if not projects.get_project(project_id):
+ raise HTTPException(status_code=404, detail="Project not found")
+ asset = projects.add_asset(
+ project_id,
+ body.asset_type,
+ body.title,
+ file_path=body.file_path,
+ created_by="ceo",
+ )
+ return {"ok": True, "asset": asset}
+
+
+@router.post("/api/projects/photos/{photo_id}/link")
+def api_link_photo(photo_id: int, body: LinkPhotoBody) -> dict[str, Any]:
+ try:
+ projects.link_photo_to_project(photo_id, body.project_id, body.created_by)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ return {"ok": True}
+
+
+@router.get("/api/preferences/ui")
+def api_get_ui_preferences() -> dict[str, Any]:
+ return ui_preferences.get_preferences("ceo")
+
+
+@router.put("/api/preferences/ui")
+def api_save_ui_preferences(body: PreferencesBody) -> dict[str, Any]:
+ allowed = {m["id"] for m in ui_preferences.VIZ_MODES}
+ if body.global_viz_mode and body.global_viz_mode not in allowed:
+ raise HTTPException(status_code=400, detail="Invalid viz mode")
+ if body.locale and body.locale not in ("nl", "en"):
+ raise HTTPException(status_code=400, detail="Invalid locale (nl or en)")
+ prefs = ui_preferences.save_preferences(
+ "ceo",
+ dashboard_layout=body.dashboard_layout,
+ global_viz_mode=body.global_viz_mode,
+ viz_modes=body.viz_modes,
+ locale=body.locale,
+ )
+ return {"ok": True, "preferences": prefs}
diff --git a/cockpit/app/routes/retail.py b/cockpit/app/routes/retail.py
index 8b5abd0..1469610 100644
--- a/cockpit/app/routes/retail.py
+++ b/cockpit/app/routes/retail.py
@@ -25,6 +25,48 @@ class CrmLinkBody(BaseModel):
notes: Optional[str] = None
+class SupermarketCreateBody(BaseModel):
+ name: str
+ chain: str
+ address: str
+ postcode: str = "0000AA"
+ city: str
+ province: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ website: Optional[str] = None
+ manager_name: Optional[str] = None
+ employee_count: Optional[int] = None
+ store_type: Optional[str] = None
+ partnership_status: str = "none"
+ halal_certified: bool = False
+ has_halal_section: bool = False
+ halal_certifier: Optional[str] = None
+
+
+class SupermarketPatchBody(BaseModel):
+ name: Optional[str] = None
+ chain: Optional[str] = None
+ address: Optional[str] = None
+ postcode: Optional[str] = None
+ city: Optional[str] = None
+ province: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ website: Optional[str] = None
+ manager_name: Optional[str] = None
+ employee_count: Optional[int] = None
+ store_type: Optional[str] = None
+ size_m2: Optional[int] = None
+ partnership_status: Optional[str] = None
+ halal_certified: Optional[bool] = None
+ has_halal_section: Optional[bool] = None
+ halal_certifier: Optional[str] = None
+ halal_certificate_number: Optional[str] = None
+ organic_section: Optional[bool] = None
+ alcohol_section: Optional[bool] = None
+
+
class NoteBody(BaseModel):
body: str
title: Optional[str] = None
@@ -88,6 +130,13 @@ async def _tools_delete(path: str) -> Any:
return resp.json()
+async def _tools_patch(path: str, json_body: Optional[dict] = None) -> Any:
+ async with httpx.AsyncClient(timeout=120) as client:
+ resp = await client.patch(f"{TOOLS}{path}", json=json_body or {})
+ resp.raise_for_status()
+ return resp.json()
+
+
@router.get("/retail")
async def retail_page(request: Request):
stats = await _tools_get("/retail/stats")
@@ -153,6 +202,24 @@ 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/supermarkets")
+async def api_create_supermarket(body: SupermarketCreateBody):
+ return JSONResponse(await _tools_post("/retail/supermarkets", json_body=body.model_dump()))
+
+
+@router.patch("/api/retail/supermarkets/{store_id}")
+async def api_patch_supermarket(store_id: int, body: SupermarketPatchBody):
+ return JSONResponse(await _tools_patch(
+ f"/retail/supermarkets/{store_id}",
+ json_body=body.model_dump(exclude_unset=True),
+ ))
+
+
+@router.delete("/api/retail/supermarkets/{store_id}/link/{client_id}")
+async def api_unlink_store(store_id: int, client_id: int):
+ return JSONResponse(await _tools_delete(f"/retail/supermarkets/{store_id}/link/{client_id}"))
+
+
@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}))
diff --git a/cockpit/app/services/agent_approvals.py b/cockpit/app/services/agent_approvals.py
new file mode 100644
index 0000000..99cc8ac
--- /dev/null
+++ b/cockpit/app/services/agent_approvals.py
@@ -0,0 +1,193 @@
+"""Agent action approval queue — gate before executing sensitive queries."""
+from __future__ import annotations
+
+import json
+from typing import Any, Optional
+
+from app.db import execute, fetch_all, fetch_one
+
+
+def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not row:
+ return None
+ out = dict(row)
+ for key, val in list(out.items()):
+ if hasattr(val, "isoformat"):
+ out[key] = val.isoformat()
+ return out
+
+
+def has_pending_request(agent_key: str, action_type: str | None = None) -> bool:
+ clauses = ["agent_key = %s", "status = 'pending'", "created_at >= CURRENT_DATE"]
+ params: list[Any] = [agent_key.strip().lower()]
+ if action_type:
+ clauses.append("action_type = %s")
+ params.append(action_type)
+ row = fetch_one(
+ f"SELECT id FROM agent_action_requests WHERE {' AND '.join(clauses)} LIMIT 1",
+ tuple(params),
+ )
+ return bool(row)
+
+
+def create_request(
+ agent_key: str,
+ title: str,
+ action_type: str = "query",
+ query_payload: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ key = (agent_key or "").strip().lower()
+ if not key or not title.strip():
+ raise ValueError("agent_key and title are required")
+
+ row = fetch_one(
+ """
+ INSERT INTO agent_action_requests (agent_key, action_type, title, query_payload, status)
+ VALUES (%s, %s, %s, %s::jsonb, 'pending')
+ RETURNING *
+ """,
+ (key, action_type, title.strip(), json.dumps(query_payload or {})),
+ )
+ req = _serialize(row) or {}
+
+ 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)
+ """,
+ (
+ key,
+ "agent_request",
+ "approval_request",
+ title.strip(),
+ f"Wacht op goedkeuring — {action_type}",
+ "needs_approval",
+ "agents",
+ json.dumps({"request_id": req.get("id"), "action_type": action_type}),
+ ),
+ )
+ except Exception:
+ pass
+ return req
+
+
+def list_requests(status: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
+ clauses, params = [], []
+ if status:
+ clauses.append("status = %s")
+ params.append(status)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ safe_limit = max(1, min(limit, 200))
+ rows = fetch_all(
+ f"SELECT * FROM agent_action_requests{where} ORDER BY created_at DESC LIMIT %s",
+ tuple(params + [safe_limit]),
+ )
+ return [_serialize(r) for r in rows]
+
+
+def get_request(request_id: int) -> dict[str, Any] | None:
+ return _serialize(fetch_one("SELECT * FROM agent_action_requests WHERE id = %s", (request_id,)))
+
+
+def approve_request(request_id: int, approved_by: str = "ceo") -> dict[str, Any]:
+ row = fetch_one(
+ """
+ UPDATE agent_action_requests
+ SET status = 'approved', approved_by = %s, reviewed_at = NOW()
+ WHERE id = %s AND status = 'pending'
+ RETURNING *
+ """,
+ (approved_by, request_id),
+ )
+ if not row:
+ raise ValueError("Request not found or not pending")
+ req = _serialize(row) or {}
+ try:
+ execute(
+ """
+ UPDATE agent_events SET status = 'approved'
+ WHERE status = 'needs_approval'
+ AND metadata->>'request_id' = %s
+ """,
+ (str(request_id),),
+ )
+ except Exception:
+ pass
+ return req
+
+
+def reject_request(request_id: int, reason: str = "", rejected_by: str = "ceo") -> dict[str, Any]:
+ row = fetch_one(
+ """
+ UPDATE agent_action_requests
+ SET status = 'rejected', approved_by = %s, rejection_reason = %s, reviewed_at = NOW()
+ WHERE id = %s AND status = 'pending'
+ RETURNING *
+ """,
+ (rejected_by, (reason or "")[:500], request_id),
+ )
+ if not row:
+ raise ValueError("Request not found or not pending")
+ req = _serialize(row) or {}
+ try:
+ execute(
+ """
+ UPDATE agent_events SET status = 'rejected'
+ WHERE status = 'needs_approval'
+ AND metadata->>'request_id' = %s
+ """,
+ (str(request_id),),
+ )
+ except Exception:
+ pass
+ return req
+
+
+def mark_executed(request_id: int, result: dict[str, Any] | None = None) -> dict[str, Any]:
+ row = fetch_one(
+ """
+ UPDATE agent_action_requests
+ SET status = 'executed', executed_at = NOW(), result = %s::jsonb
+ WHERE id = %s AND status = 'approved'
+ RETURNING *
+ """,
+ (json.dumps(result or {}), request_id),
+ )
+ if not row:
+ raise ValueError("Request not approved or not found")
+ req = _serialize(row) or {}
+
+ try:
+ from app.services import projects as project_svc
+
+ payload = req.get("query_payload") or {}
+ if isinstance(payload, str):
+ import json as _json
+ try:
+ payload = _json.loads(payload)
+ except Exception:
+ payload = {}
+ pid = payload.get("project_id")
+ project_svc.register_agent_output(
+ asset_type=str(req.get("action_type") or "agent_action"),
+ title=req.get("title") or f"Agent actie #{request_id}",
+ ref_id=str(request_id),
+ payload={"result": result or {}, "action_type": req.get("action_type")},
+ project_id=int(pid) if pid else None,
+ source_agent=str(req.get("agent_key") or "agent"),
+ created_by=str(req.get("approved_by") or "ceo"),
+ )
+ except Exception:
+ pass
+
+ return req
+
+
+def require_approved(request_id: int) -> dict[str, Any]:
+ req = get_request(request_id)
+ if not req:
+ raise PermissionError("Approval request not found")
+ if req.get("status") != "approved":
+ raise PermissionError(f"Request status is {req.get('status')}, approval required")
+ return req
diff --git a/cockpit/app/services/agent_souls.py b/cockpit/app/services/agent_souls.py
index 26ed20e..3044260 100644
--- a/cockpit/app/services/agent_souls.py
+++ b/cockpit/app/services/agent_souls.py
@@ -10,7 +10,13 @@ def list_souls() -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
- (SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at
+ (SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
+ (SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
+ ORDER BY created_at DESC LIMIT 1) AS current_task,
+ (SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
+ ORDER BY created_at DESC LIMIT 1) AS current_status,
+ (SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
+ ORDER BY created_at DESC LIMIT 1) AS current_event_type
FROM agent_souls s ORDER BY s.display_name"""
)
return [dict(r) for r in rows]
@@ -25,13 +31,27 @@ def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
)
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]
+ out["recent_events"] = list_agent_events(agent_key, limit=15)
+ return out
+
+
+def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
+ rows = fetch_all(
+ """SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
+ FROM agent_events
+ WHERE LOWER(agent_name) = %s
+ ORDER BY created_at DESC
+ LIMIT %s""",
+ (agent_key.lower(), limit),
+ )
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ ev = dict(row)
+ for key in ("created_at", "completed_at"):
+ if ev.get(key) is not None and hasattr(ev[key], "isoformat"):
+ ev[key] = ev[key].isoformat()
+ out.append(ev)
return out
diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py
index d459859..71df946 100644
--- a/cockpit/app/services/briefing.py
+++ b/cockpit/app/services/briefing.py
@@ -54,6 +54,74 @@ def collect_briefing_data() -> dict[str, Any]:
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
+ try:
+ data["pending_approval_requests"] = fetch_all(
+ """SELECT id, agent_key, action_type, title, query_payload, created_at
+ FROM agent_action_requests WHERE status = 'pending'
+ ORDER BY created_at ASC LIMIT 15"""
+ )
+ data["pending_approvals"] = len(data["pending_approval_requests"])
+ except Exception:
+ data["pending_approval_requests"] = []
+
+ try:
+ data["recent_executed_actions"] = fetch_all(
+ """SELECT id, agent_key, action_type, title, result, executed_at, approved_by
+ FROM agent_action_requests
+ WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY executed_at DESC LIMIT 12"""
+ )
+ except Exception:
+ data["recent_executed_actions"] = []
+
+ try:
+ data["project_assets_recent"] = fetch_all(
+ """SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
+ FROM project_assets pa
+ JOIN cockpit_projects cp ON cp.id = pa.project_id
+ WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY pa.created_at DESC LIMIT 15"""
+ )
+ except Exception:
+ data["project_assets_recent"] = []
+
+ try:
+ data["ops_maintenance_open"] = fetch_all(
+ """SELECT severity, title, body, created_at FROM ops_maintenance_notes
+ WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
+ )
+ except Exception:
+ data["ops_maintenance_open"] = []
+
+ try:
+ data["config_backups_recent"] = fetch_all(
+ """SELECT status, message, commit_ref, created_at FROM config_backups
+ ORDER BY created_at DESC LIMIT 5"""
+ )
+ except Exception:
+ data["config_backups_recent"] = []
+
+ try:
+ data["sysops_activity_24h"] = fetch_all(
+ """SELECT action_type, title, body, commit_ref, files_changed, status, created_at
+ FROM sysops_activity
+ WHERE created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 20"""
+ )
+ except Exception:
+ data["sysops_activity_24h"] = []
+
+ try:
+ data["sysops_events_24h"] = fetch_all(
+ """SELECT event_type, title, body, status, created_at, metadata
+ FROM agent_events
+ WHERE LOWER(agent_name) = 'sysops'
+ AND created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 15"""
+ )
+ except Exception:
+ data["sysops_events_24h"] = []
+
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"
@@ -204,20 +272,85 @@ def collect_briefing_data() -> dict[str, Any]:
except Exception:
data["regulation_highlights"] = []
+ try:
+ data["trending_food"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
+ f.category, i.published_at
+ FROM rss_items i
+ JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
+ OR f.name ILIKE '%retaildetail%'
+ ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
+ )
+ except Exception:
+ data["trending_food"] = []
+
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%'])
+ WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
+ OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
+ data["activity_log"] = _build_activity_log(data)
return data
+def _build_activity_log(data: dict[str, Any]) -> list[str]:
+ lines: list[str] = []
+ for row in data.get("pending_approval_requests") or []:
+ agent = row.get("agent_key") or "agent"
+ action = row.get("action_type") or "actie"
+ title = row.get("title") or ""
+ if action == "maintenance_scan":
+ lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
+ elif action == "config_backup":
+ lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
+ else:
+ lines.append(f"⏳ {agent} wacht op goedkeuring ({action}): {title}")
+
+ for row in data.get("recent_executed_actions") or []:
+ lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
+
+ for row in data.get("project_assets_recent") or []:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
+ lines.append(
+ f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
+ f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
+ )
+
+ for row in data.get("ops_maintenance_open") or []:
+ lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
+
+ for row in data.get("config_backups_recent") or []:
+ lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
+
+ for row in data.get("sysops_activity_24h") or []:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
+ lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
+
+ for row in data.get("sysops_events_24h") or []:
+ if (row.get("event_type") or "") == "gitea_sync":
+ continue
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
+
+ for row in (data.get("recent_events") or [])[:8]:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
+
+ return lines[:25]
+
+
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
@@ -272,8 +405,30 @@ def build_template_report(data: dict[str, Any]) -> str:
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
+ if data.get("sysops_activity_24h"):
+ lines.extend(["", "## SysOps IT — laatste 24 uur"])
+ for row in data["sysops_activity_24h"][:12]:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
+ lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
+ if row.get("body"):
+ lines.append(f" {str(row.get('body'))[:200]}")
+
+ if data.get("pending_approval_requests"):
+ lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
+ for row in data["pending_approval_requests"]:
+ action = row.get("action_type") or ""
+ label = "Update-scan VM106" if action == "maintenance_scan" else action
+ lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
+
+ if data.get("activity_log"):
+ lines.extend(["", "## Herman activiteitenlog (24u)"])
+ for entry in data["activity_log"][:20]:
+ lines.append(f"- {entry}")
+
if data.get("pending_items"):
- lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
+ lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
@@ -289,21 +444,26 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
+ activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
+
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"
+ "## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
+ "## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
+ "## Lange termijn focus\n(3-5 bullets)\n\n"
+ "## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
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"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
+ f"Activiteitenlog:\n{activity}\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."
+ "Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
+ "Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
+ "Schrijf warm, professioneel, actionable."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
@@ -327,10 +487,14 @@ def _fallback_summary(data: dict[str, Any]) -> str:
)
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
+ f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
"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",
]
+ for row in data.get("pending_approval_requests") or []:
+ if row.get("action_type") == "maintenance_scan":
+ actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
+ break
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
@@ -354,6 +518,18 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
)
except Exception:
pass
+ try:
+ execute(
+ """INSERT INTO llm_memory (category, subject, content, source, metadata, updated_at)
+ VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
+ (
+ f"Briefing {data['date']}",
+ content[:8000],
+ json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
+ ),
+ )
+ except Exception:
+ pass
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
@@ -361,7 +537,7 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
- "completed", "dashboard", json.dumps({"stats": safe}),
+ "completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
),
)
except Exception:
diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py
index 0cafe8b..1e19b8f 100644
--- a/cockpit/app/services/herman.py
+++ b/cockpit/app/services/herman.py
@@ -5,6 +5,7 @@ import httpx
from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
+from app.services import packaging_agent
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
@@ -14,6 +15,7 @@ AGENTS: dict[str, dict[str, str]] = {
"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."},
+ "packaging": {"name": "Packaging", "persona": "SVG/PDF verpakkingsontwerp, stanstekeningen, drukwerk."},
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
}
@@ -82,6 +84,72 @@ def _pick_agent(raw: str) -> str:
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
+ if packaging_agent.wants_packaging(message):
+ try:
+ outcome = await packaging_agent.generate_from_message(message)
+ name = outcome.get("design_name") or "Design"
+ pid = outcome.get("packaging_id", "")[:8]
+ reply_lines = [
+ f"Packaging agent heeft een design klaar voor je: {name}",
+ f"Type: {outcome.get('type')} · {outcome.get('dimensions')}",
+ f"Project #{outcome.get('cockpit_project_id')}",
+ f"Studio: {outcome.get('studio_url')}",
+ f"PDF: {outcome.get('pdf_url')}",
+ ]
+ if outcome.get("nas", {}).get("ok"):
+ reply_lines.append("Bestanden staan op de NAS in de packaging-map van het project.")
+ reply = "\n".join(reply_lines)
+
+ await _log_event(
+ "packaging",
+ "packaging_created",
+ f"Design klaar: {name}",
+ f"Herman-opdracht: {message[:1500]}\n\nDesign-ID: {outcome.get('packaging_id')}",
+ {
+ "packaging_id": outcome.get("packaging_id"),
+ "project_id": outcome.get("cockpit_project_id"),
+ "studio_url": outcome.get("studio_url"),
+ "pdf_url": outcome.get("pdf_url"),
+ "nas": outcome.get("nas"),
+ "for_herman": True,
+ },
+ )
+ await _log_event(
+ "herman",
+ "packaging_delivered",
+ f"Packaging → Herman: {name}",
+ reply[:2000],
+ {
+ "source_agent": "packaging",
+ "packaging_id": outcome.get("packaging_id"),
+ "project_id": outcome.get("cockpit_project_id"),
+ },
+ )
+ return {
+ "agent": "packaging",
+ "agent_label": "Packaging → Herman",
+ "reply": reply,
+ "delegated_agents": ["packaging", "herman"],
+ "routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
+ "packaging_id": outcome.get("packaging_id"),
+ "packaging_studio_url": outcome.get("studio_url"),
+ "packaging_pdf_url": outcome.get("pdf_url"),
+ }
+ except Exception as exc:
+ await _log_event(
+ "packaging",
+ "packaging_error",
+ "Packaging generatie mislukt",
+ str(exc)[:1500],
+ {"message": message[:500]},
+ )
+ return {
+ "agent": "packaging",
+ "agent_label": "Packaging",
+ "reply": f"Packaging agent kon geen design maken: {exc}",
+ "delegated_agents": ["packaging"],
+ }
+
if _wants_image(message):
prompt = _extract_image_prompt(message)
try:
diff --git a/cockpit/app/services/nas_folders.py b/cockpit/app/services/nas_folders.py
new file mode 100644
index 0000000..a978a9f
--- /dev/null
+++ b/cockpit/app/services/nas_folders.py
@@ -0,0 +1,104 @@
+"""NAS folder structure per client and project — geen alles-op-een-hoop."""
+from __future__ import annotations
+
+import json
+import os
+import re
+from pathlib import Path
+from typing import Any, Optional
+
+from app.db import execute, fetch_one
+
+NAS_ROOT = Path(os.getenv("NAS_CLIENTS_ROOT", "/data/nas-clients"))
+
+PROJECT_SUBDIRS = ("photos", "documents", "packaging", "exports", "briefs")
+
+PROJECT_TYPES = [
+ {"id": "packaging", "label_nl": "Verpakking & label", "label_en": "Packaging & label", "icon": "📦", "nas_sub": "packaging"},
+ {"id": "retail_listing", "label_nl": "Retail listing / schap", "label_en": "Retail listing", "icon": "🏪", "nas_sub": "documents"},
+ {"id": "recipe", "label_nl": "Recept & productontwikkeling", "label_en": "Recipe & R&D", "icon": "🍱", "nas_sub": "briefs"},
+ {"id": "marketing", "label_nl": "Marketing campagne", "label_en": "Marketing campaign", "icon": "📣", "nas_sub": "exports"},
+ {"id": "halal", "label_nl": "Halal certificering", "label_en": "Halal certification", "icon": "☪️", "nas_sub": "documents"},
+ {"id": "sourcing", "label_nl": "Sourcing & import", "label_en": "Sourcing & import", "icon": "🚢", "nas_sub": "documents"},
+ {"id": "crm", "label_nl": "Klant & partnership", "label_en": "Client & partnership", "icon": "🤝", "nas_sub": "documents"},
+ {"id": "research", "label_nl": "Marktonderzoek", "label_en": "Market research", "icon": "🔬", "nas_sub": "briefs"},
+ {"id": "general", "label_nl": "Algemeen project", "label_en": "General project", "icon": "📁", "nas_sub": "documents"},
+]
+
+
+def slugify(name: str, max_len: int = 48) -> str:
+ s = re.sub(r"[^a-zA-Z0-9]+", "-", (name or "project").strip().lower()).strip("-")
+ return (s[:max_len] or "project")
+
+
+def _write_meta(path: Path, data: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def ensure_client_folder(client_id: int, client_name: str) -> str:
+ """Maak NAS-map per klant: clients/{slug}/ met submappen."""
+ slug = slugify(client_name)
+ root = NAS_ROOT / slug
+ for sub in ("projects", "photos", "documents", "inbox"):
+ (root / sub).mkdir(parents=True, exist_ok=True)
+ meta = {
+ "client_id": client_id,
+ "client_name": client_name,
+ "slug": slug,
+ "structure": ["projects", "photos", "documents", "inbox"],
+ }
+ _write_meta(root / "client.json", meta)
+ rel = str(root)
+ execute("UPDATE clients SET nas_folder = %s WHERE id = %s", (rel, client_id))
+ return rel
+
+
+def ensure_project_folder(
+ project_id: int,
+ project_name: str,
+ client_id: Optional[int],
+ client_name: Optional[str],
+ project_type: str = "general",
+) -> dict[str, Any]:
+ """Projectmap onder klant: clients/{client}/projects/{project}/"""
+ client_root: Path | None = None
+ if client_id and client_name:
+ row = fetch_one("SELECT nas_folder FROM clients WHERE id = %s", (client_id,))
+ if row and row.get("nas_folder"):
+ client_root = Path(row["nas_folder"])
+ else:
+ client_root = Path(ensure_client_folder(client_id, client_name))
+ else:
+ client_root = NAS_ROOT / "_geen-klant"
+ client_root.mkdir(parents=True, exist_ok=True)
+
+ proj_slug = f"{project_id}-{slugify(project_name)}"
+ proj_root = client_root / "projects" / proj_slug
+ for sub in PROJECT_SUBDIRS:
+ (proj_root / sub).mkdir(parents=True, exist_ok=True)
+
+ type_info = next((t for t in PROJECT_TYPES if t["id"] == project_type), PROJECT_TYPES[-1])
+ _write_meta(
+ proj_root / "project.json",
+ {
+ "project_id": project_id,
+ "name": project_name,
+ "client_id": client_id,
+ "project_type": project_type,
+ "folders": list(PROJECT_SUBDIRS),
+ "primary_sub": type_info.get("nas_sub", "documents"),
+ },
+ )
+
+ nas_path = str(proj_root)
+ client_rel = str(client_root)
+ execute(
+ """UPDATE cockpit_projects SET nas_path = %s, nas_client_root = %s, updated_at = NOW() WHERE id = %s""",
+ (nas_path, client_rel, project_id),
+ )
+ return {"nas_path": nas_path, "nas_client_root": client_rel, "project_slug": proj_slug}
+
+
+def list_types() -> list[dict[str, Any]]:
+ return list(PROJECT_TYPES)
diff --git a/cockpit/app/services/packaging_agent.py b/cockpit/app/services/packaging_agent.py
new file mode 100644
index 0000000..0919dc9
--- /dev/null
+++ b/cockpit/app/services/packaging_agent.py
@@ -0,0 +1,232 @@
+"""Packaging agent — parse Herman-opdrachten en genereer designs."""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import httpx
+
+from app.config import settings
+from app.services import packaging_nas, projects
+
+PACKAGING_KEYWORDS = (
+ "maak verpakking",
+ "maak een verpakking",
+ "genereer verpakking",
+ "ontwerp verpakking",
+ "packaging:",
+ "packaging ",
+ "/packaging",
+ "maak packaging",
+ "maak package",
+ "stanstekening",
+ "verpakkingsontwerp",
+)
+
+TYPE_ALIASES: list[tuple[str, str]] = [
+ ("folding box", "folding_box"),
+ ("folding_box", "folding_box"),
+ ("sluitdoos", "folding_box"),
+ ("doos", "folding_box"),
+ ("banderole", "wrap"),
+ ("wrap", "wrap"),
+ ("rond label", "round_label"),
+ ("round label", "round_label"),
+ ("round_label", "round_label"),
+ ("sleeve", "sleeve"),
+ ("huls", "sleeve"),
+ ("pouch", "pouch"),
+ ("zak", "pouch"),
+ ("tray", "tray"),
+ ("schaal", "tray"),
+]
+
+DEFAULT_ELEMENTS = {
+ "barcode": True,
+ "logo_area": True,
+ "fold_lines": True,
+ "cut_lines": True,
+ "nutrition_panel": False,
+ "ingredients": True,
+ "halal_badge": False,
+ "window": False,
+ "qr_code": False,
+ "glue_tabs": False,
+ "bleed": True,
+ "dimensions": True,
+}
+
+
+def wants_packaging(raw: str) -> bool:
+ t = (raw or "").strip().lower()
+ return any(k in t for k in PACKAGING_KEYWORDS)
+
+
+def extract_packaging_body(raw: str) -> str:
+ t = raw.strip()
+ lower = t.lower()
+ for k in PACKAGING_KEYWORDS:
+ if lower.startswith(k):
+ rest = t[len(k) :].strip(" :,-")
+ if rest:
+ return rest
+ for k in PACKAGING_KEYWORDS:
+ if k in lower:
+ idx = lower.index(k) + len(k)
+ rest = t[idx:].strip(" :,-")
+ if rest:
+ return rest
+ return t
+
+
+def _detect_type(text: str) -> str:
+ lower = text.lower()
+ for alias, ptype in TYPE_ALIASES:
+ if alias in lower:
+ return ptype
+ return "folding_box"
+
+
+def _detect_dimensions(text: str) -> tuple[float, float, float]:
+ m = re.search(r"(\d{2,4})\s*[x×]\s*(\d{2,4})(?:\s*[x×]\s*(\d{1,4}))?", text, re.I)
+ if m:
+ w, h = float(m.group(1)), float(m.group(2))
+ d = float(m.group(3)) if m.group(3) else (40.0 if _detect_type(text) == "folding_box" else 20.0)
+ return w, h, d
+ return 120.0, 80.0, 40.0
+
+
+def _detect_project_id(text: str) -> int | None:
+ m = re.search(r"project\s*#?\s*(\d+)", text, re.I)
+ if m:
+ return int(m.group(1))
+ m = re.search(r"\bproject\s+(\d+)\b", text, re.I)
+ return int(m.group(1)) if m else None
+
+
+def _detect_product_name(text: str) -> str:
+ m = re.search(r'voor\s+["\']?([^"\']+?)["\']?(?:\s+project|\s*$|,)', text, re.I)
+ if m:
+ return m.group(1).strip()[:80]
+ m = re.search(r'product\s*[:=]\s*["\']?([^"\']+)["\']?', text, re.I)
+ if m:
+ return m.group(1).strip()[:80]
+ cleaned = text
+ for alias, _ in TYPE_ALIASES:
+ cleaned = re.sub(re.escape(alias), "", cleaned, flags=re.I)
+ cleaned = re.sub(r"\d{2,4}\s*[x×]\s*\d{2,4}(?:\s*[x×]\s*\d{1,4})?", "", cleaned, flags=re.I)
+ cleaned = re.sub(r"project\s*#?\s*\d+", "", cleaned, flags=re.I)
+ for kw in ("halal-badge", "halal badge", "voedingswaarden", "nutrition", "qr-code", "qr code", "venster", "window"):
+ cleaned = re.sub(re.escape(kw), "", cleaned, flags=re.I)
+ cleaned = cleaned.strip(" ,:-")
+ return (cleaned[:80] or "Foodlinkk Product")
+
+
+def parse_packaging_request(message: str) -> dict[str, Any]:
+ body = extract_packaging_body(message)
+ lower = body.lower()
+ ptype = _detect_type(body)
+ w, h, d = _detect_dimensions(body)
+ product = _detect_product_name(body)
+ project_id = _detect_project_id(message) or _detect_project_id(body)
+
+ elements = dict(DEFAULT_ELEMENTS)
+ if any(k in lower for k in ("halal", "halal-badge", "halal badge")):
+ elements["halal_badge"] = True
+ if any(k in lower for k in ("voedingswaarden", "nutrition")):
+ elements["nutrition_panel"] = True
+ if any(k in lower for k in ("qr", "qrcode")):
+ elements["qr_code"] = True
+ if "venster" in lower or "window" in lower:
+ elements["window"] = True
+
+ return {
+ "type": ptype,
+ "width_mm": w,
+ "height_mm": h,
+ "depth_mm": d,
+ "bleed_mm": 3,
+ "design_name": product,
+ "barcode_value": "8710000000012",
+ "elements": elements,
+ "text": {
+ "product_name": product,
+ "tagline": "Premium halal kant-en-klaar",
+ "subtitle": "",
+ "ingredients": "",
+ "origin": "Geproduceerd in NL",
+ "best_before": "Ten minste houdbaar tot: zie verpakking",
+ },
+ "brand": {},
+ "project_id": project_id,
+ "created_by": "packaging",
+ }
+
+
+async def _download_bytes(packaging_id: str, fmt: str) -> bytes | None:
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{packaging_id}",
+ params={"format": fmt},
+ )
+ if r.status_code < 400:
+ return r.content
+ except Exception:
+ pass
+ return None
+
+
+async def generate_from_message(message: str) -> dict[str, Any]:
+ """Genereer packaging design en exporteer naar NAS; retour voor Herman-reply."""
+ spec = parse_packaging_request(message)
+
+ if not spec.get("project_id"):
+ proj = projects.create_project(
+ f"Packaging · {spec['design_name'][:48]}",
+ description=f"Aangemaakt door packaging agent via Herman\n\nOpdracht: {message[:500]}",
+ project_type="packaging",
+ ensure_nas=True,
+ created_by="packaging",
+ )
+ spec["project_id"] = proj.get("id")
+
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate",
+ json=spec,
+ )
+ r.raise_for_status()
+ result = r.json()
+
+ packaging_id = result.get("id", "")
+ cockpit_project_id = result.get("cockpit_project_id") or spec.get("project_id")
+ svg = result.get("svg") or ""
+ saved_spec = result.get("spec") or spec
+
+ nas_info: dict[str, Any] = {}
+ if packaging_id and cockpit_project_id and svg:
+ png_b = await _download_bytes(packaging_id, "png")
+ pdf_b = await _download_bytes(packaging_id, "pdf")
+ try:
+ nas_info = packaging_nas.export_packaging_files(
+ packaging_id, int(cockpit_project_id), svg, saved_spec, png_b, pdf_b
+ )
+ except Exception as exc:
+ nas_info = {"ok": False, "error": str(exc)}
+
+ studio_url = f"/packaging?project_id={cockpit_project_id}"
+ pdf_url = f"/api/packaging/download/{packaging_id}?format=pdf"
+
+ return {
+ "packaging_id": packaging_id,
+ "cockpit_project_id": cockpit_project_id,
+ "design_name": saved_spec.get("design_name") or spec.get("design_name"),
+ "type": saved_spec.get("type"),
+ "dimensions": f"{saved_spec.get('width_mm')}×{saved_spec.get('height_mm')}×{saved_spec.get('depth_mm')} mm",
+ "studio_url": studio_url,
+ "pdf_url": pdf_url,
+ "nas": nas_info,
+ "spec": saved_spec,
+ "original_message": message,
+ }
diff --git a/cockpit/app/services/packaging_nas.py b/cockpit/app/services/packaging_nas.py
new file mode 100644
index 0000000..ed020ee
--- /dev/null
+++ b/cockpit/app/services/packaging_nas.py
@@ -0,0 +1,93 @@
+"""Export packaging designs naar NAS projectmap."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from app.db import execute, fetch_one
+from app.services import nas_folders
+
+
+def _packaging_dir(project_id: int) -> Path | None:
+ row = fetch_one(
+ """
+ SELECT p.id, p.name, p.nas_path, p.client_id, p.project_type, c.name AS client_name
+ FROM cockpit_projects p
+ LEFT JOIN clients c ON c.id = p.client_id
+ WHERE p.id = %s
+ """,
+ (project_id,),
+ )
+ if not row:
+ return None
+ nas_path = row.get("nas_path")
+ if not nas_path:
+ try:
+ paths = nas_folders.ensure_project_folder(
+ int(row["id"]),
+ row["name"] or f"project-{project_id}",
+ row.get("client_id"),
+ row.get("client_name"),
+ row.get("project_type") or "packaging",
+ )
+ nas_path = paths.get("nas_path")
+ except Exception:
+ return None
+ root = Path(nas_path) / "packaging"
+ root.mkdir(parents=True, exist_ok=True)
+ return root
+
+
+def export_packaging_files(
+ packaging_id: str,
+ cockpit_project_id: int,
+ svg_content: str,
+ spec: dict[str, Any],
+ png_bytes: bytes | None = None,
+ pdf_bytes: bytes | None = None,
+) -> dict[str, Any]:
+ """Schrijf SVG/PNG/PDF + manifest naar NAS packaging/ submap."""
+ root = _packaging_dir(cockpit_project_id)
+ if not root:
+ return {"ok": False, "error": "Geen NAS-map voor project"}
+
+ design_name = spec.get("design_name") or spec.get("text", {}).get("product_name") or packaging_id[:8]
+ slug = nas_folders.slugify(str(design_name))[:32]
+ base = f"{packaging_id[:8]}-{slug}"
+
+ paths: dict[str, str] = {}
+ svg_path = root / f"{base}.svg"
+ svg_path.write_text(svg_content, encoding="utf-8")
+ paths["svg"] = str(svg_path)
+
+ if png_bytes:
+ png_path = root / f"{base}.png"
+ png_path.write_bytes(png_bytes)
+ paths["png"] = str(png_path)
+
+ if pdf_bytes:
+ pdf_path = root / f"{base}.pdf"
+ pdf_path.write_bytes(pdf_bytes)
+ paths["pdf"] = str(pdf_path)
+
+ manifest = {
+ "packaging_id": packaging_id,
+ "design_name": design_name,
+ "spec": spec,
+ "files": paths,
+ }
+ manifest_path = root / f"{base}.json"
+ manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
+ paths["manifest"] = str(manifest_path)
+
+ primary = paths.get("pdf") or paths.get("svg")
+ execute(
+ """
+ UPDATE project_assets SET file_path = %s
+ WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s
+ """,
+ (primary, cockpit_project_id, packaging_id),
+ )
+
+ return {"ok": True, "nas_packaging_dir": str(root), "files": paths}
diff --git a/cockpit/app/services/projects.py b/cockpit/app/services/projects.py
new file mode 100644
index 0000000..245152b
--- /dev/null
+++ b/cockpit/app/services/projects.py
@@ -0,0 +1,234 @@
+"""Unified cockpit projects and cross-module assets."""
+from __future__ import annotations
+
+import json
+from typing import Any, Optional
+
+from app.db import execute, fetch_all, fetch_one
+from app.services import nas_folders
+
+
+def _row(row: dict | None) -> dict | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ return out
+
+
+def list_projects(client_id: Optional[int] = None, limit: int = 50) -> list[dict[str, Any]]:
+ clauses, params = [], []
+ if client_id:
+ clauses.append("p.client_id = %s")
+ params.append(client_id)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ safe = max(1, min(limit, 200))
+ rows = fetch_all(
+ f"""
+ SELECT p.*, c.name AS client_name,
+ (SELECT COUNT(*) FROM project_assets a WHERE a.project_id = p.id) AS asset_count
+ FROM cockpit_projects p
+ LEFT JOIN clients c ON c.id = p.client_id
+ {where}
+ ORDER BY p.updated_at DESC, p.id DESC
+ LIMIT %s
+ """,
+ tuple(params + [safe]),
+ )
+ return [_row(r) for r in rows]
+
+
+def get_project(project_id: int) -> dict[str, Any] | None:
+ row = fetch_one(
+ """
+ SELECT p.*, c.name AS client_name
+ FROM cockpit_projects p
+ LEFT JOIN clients c ON c.id = p.client_id
+ WHERE p.id = %s
+ """,
+ (project_id,),
+ )
+ if not row:
+ return None
+ out = _row(row) or {}
+ assets = fetch_all(
+ "SELECT * FROM project_assets WHERE project_id = %s ORDER BY created_at DESC",
+ (project_id,),
+ )
+ out["assets"] = [_row(a) for a in assets]
+ return out
+
+
+def create_project(
+ name: str,
+ client_id: Optional[int] = None,
+ description: str = "",
+ created_by: str = "ceo",
+ metadata: dict | None = None,
+ project_type: str = "general",
+ priority: str = "normal",
+ ensure_nas: bool = True,
+) -> dict[str, Any]:
+ row = fetch_one(
+ """
+ INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, project_type, priority, updated_at)
+ VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s, NOW())
+ RETURNING *
+ """,
+ (name.strip(), client_id, description or None, created_by, json.dumps(metadata or {}), project_type, priority),
+ )
+ out = _row(row) or {}
+ if ensure_nas and out.get("id"):
+ client_name = None
+ if client_id:
+ c = fetch_one("SELECT name FROM clients WHERE id = %s", (client_id,))
+ client_name = c["name"] if c else None
+ try:
+ paths = nas_folders.ensure_project_folder(
+ int(out["id"]), name, client_id, client_name, project_type
+ )
+ out.update(paths)
+ except Exception as exc:
+ out["nas_error"] = str(exc)
+ return out
+
+
+def update_project(project_id: int, **fields: Any) -> dict[str, Any] | None:
+ allowed = ("name", "description", "status", "client_id", "project_type", "priority")
+ 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:
+ return get_project(project_id)
+ params.append(project_id)
+ execute(f"UPDATE cockpit_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
+ return get_project(project_id)
+
+
+def project_stats() -> dict[str, Any]:
+ total = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects") or {"n": 0}
+ by_type = fetch_all(
+ "SELECT COALESCE(project_type, 'general') AS t, COUNT(*) AS n FROM cockpit_projects GROUP BY t"
+ )
+ with_nas = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE nas_path IS NOT NULL")
+ with_client = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE client_id IS NOT NULL")
+ assets = fetch_one("SELECT COUNT(*) AS n FROM project_assets")
+ return {
+ "total": int(total.get("n") or 0),
+ "with_nas": int((with_nas or {}).get("n") or 0),
+ "with_client": int((with_client or {}).get("n") or 0),
+ "assets": int((assets or {}).get("n") or 0),
+ "by_type": {r["t"]: int(r["n"]) for r in by_type},
+ }
+
+
+def add_asset(
+ project_id: int,
+ asset_type: str,
+ title: str,
+ ref_id: str | None = None,
+ file_path: str | None = None,
+ payload: dict | None = None,
+ created_by: str = "ceo",
+ source_agent: str | None = None,
+) -> dict[str, Any]:
+ row = fetch_one(
+ """
+ INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
+ RETURNING *
+ """,
+ (
+ project_id,
+ asset_type,
+ ref_id,
+ title,
+ file_path,
+ json.dumps(payload or {}),
+ created_by,
+ source_agent,
+ ),
+ )
+ execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (project_id,))
+ return _row(row) or {}
+
+
+def get_or_create_agent_project(agent_key: str, client_id: Optional[int] = None) -> int:
+ key = (agent_key or "agent").strip().lower()
+ row = fetch_one(
+ """
+ SELECT id FROM cockpit_projects
+ WHERE metadata->>'auto_agent' = %s AND status = 'active'
+ ORDER BY updated_at DESC LIMIT 1
+ """,
+ (key,),
+ )
+ if row:
+ return int(row["id"])
+ created = create_project(
+ f"Agent · {key}",
+ client_id=client_id,
+ description=f"Automatisch project voor {key} output",
+ created_by=key,
+ metadata={"auto_agent": key},
+ )
+ return int(created["id"])
+
+
+def register_agent_output(
+ asset_type: str,
+ title: str,
+ ref_id: str | None = None,
+ payload: dict | None = None,
+ project_id: Optional[int] = None,
+ source_agent: str | None = None,
+ created_by: str = "ceo",
+ file_path: str | None = None,
+) -> dict[str, Any] | None:
+ agent = (source_agent or created_by or "agent").strip().lower()
+ pid = project_id or get_or_create_agent_project(agent)
+
+ if ref_id:
+ existing = fetch_one(
+ """
+ SELECT id FROM project_assets
+ WHERE project_id = %s AND asset_type = %s AND ref_id = %s
+ LIMIT 1
+ """,
+ (pid, asset_type, ref_id),
+ )
+ if existing:
+ return {"id": existing["id"], "project_id": pid, "dedup": True}
+
+ return add_asset(
+ pid,
+ asset_type,
+ title,
+ ref_id=ref_id,
+ file_path=file_path,
+ payload=payload,
+ created_by=created_by or agent,
+ source_agent=agent,
+ )
+
+
+def link_photo_to_project(photo_id: int, project_id: int, created_by: str = "ceo") -> None:
+ photo = fetch_one("SELECT id, filename, source FROM photo_imports WHERE id = %s", (photo_id,))
+ if not photo:
+ raise ValueError("Photo not found")
+ execute(
+ "UPDATE photo_imports SET project_id = %s, created_by = COALESCE(created_by, %s) WHERE id = %s",
+ (project_id, created_by, photo_id),
+ )
+ add_asset(
+ project_id,
+ "photo",
+ photo.get("filename") or f"Foto #{photo_id}",
+ ref_id=str(photo_id),
+ created_by=created_by,
+ source_agent=photo.get("source"),
+ )
diff --git a/cockpit/app/services/social_publish.py b/cockpit/app/services/social_publish.py
index 0b6ec00..27f5d39 100644
--- a/cockpit/app/services/social_publish.py
+++ b/cockpit/app/services/social_publish.py
@@ -244,7 +244,14 @@ def test_connection(platform: str, integration: dict[str, Any] | None = None) ->
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:
+def run_publish_job(
+ job_id: int,
+ text: str,
+ channels: list[str],
+ image_url: str | None,
+ media_ids: list[int],
+ project_id: int | None = None,
+) -> None:
started_at = datetime.utcnow()
execute(
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s",
@@ -310,5 +317,26 @@ def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str
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},
+ metadata={"job_id": job_id, "results": results, "project_id": project_id},
)
+
+ try:
+ from app.services import projects as project_svc
+
+ project_svc.register_agent_output(
+ asset_type="social_publish",
+ title=f"Social publish #{job_id} · {final_status}",
+ ref_id=str(job_id),
+ payload={
+ "job_id": job_id,
+ "status": final_status,
+ "channels": channels,
+ "published": published,
+ "text_preview": (text or "")[:200],
+ },
+ project_id=project_id,
+ source_agent="marketing",
+ created_by="marketing_automation",
+ )
+ except Exception:
+ pass
diff --git a/cockpit/app/services/ui_preferences.py b/cockpit/app/services/ui_preferences.py
new file mode 100644
index 0000000..a8078de
--- /dev/null
+++ b/cockpit/app/services/ui_preferences.py
@@ -0,0 +1,82 @@
+"""User UI preferences — dashboard layout and visualization modes."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from app.db import execute, fetch_one
+
+DEFAULT_LAYOUT = ["kpis", "executive", "briefing", "retail", "analytics", "approvals", "feed"]
+DEFAULT_VIZ = "neo-bars"
+
+VIZ_MODES = [
+ {"id": "neo-bars", "label": "Neo staafdiagram", "icon": "📊"},
+ {"id": "neo-rings", "label": "Neo ringen", "icon": "🍩"},
+ {"id": "neo-equalizer", "label": "Neo equalizer", "icon": "🎚️"},
+ {"id": "neo-cards", "label": "Neo kaarten", "icon": "🃏"},
+ {"id": "neo-table", "label": "Neo tabel", "icon": "📋"},
+]
+
+
+def get_preferences(user_key: str = "ceo") -> dict[str, Any]:
+ row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,))
+ if not row:
+ return {
+ "user_key": user_key,
+ "dashboard_layout": DEFAULT_LAYOUT,
+ "viz_modes": {},
+ "global_viz_mode": DEFAULT_VIZ,
+ "locale": "nl",
+ "viz_options": VIZ_MODES,
+ }
+ layout = row.get("dashboard_layout") or DEFAULT_LAYOUT
+ if isinstance(layout, str):
+ try:
+ layout = json.loads(layout)
+ except Exception:
+ layout = DEFAULT_LAYOUT
+ viz_modes = row.get("viz_modes") or {}
+ if isinstance(viz_modes, str):
+ try:
+ viz_modes = json.loads(viz_modes)
+ except Exception:
+ viz_modes = {}
+ return {
+ "user_key": user_key,
+ "dashboard_layout": layout,
+ "viz_modes": viz_modes,
+ "global_viz_mode": row.get("global_viz_mode") or DEFAULT_VIZ,
+ "locale": row.get("locale") or "nl",
+ "viz_options": VIZ_MODES,
+ }
+
+
+def save_preferences(
+ user_key: str,
+ dashboard_layout: list[str] | None = None,
+ global_viz_mode: str | None = None,
+ viz_modes: dict[str, str] | None = None,
+ locale: str | None = None,
+) -> dict[str, Any]:
+ current = get_preferences(user_key)
+ layout = dashboard_layout if dashboard_layout is not None else current["dashboard_layout"]
+ gviz = global_viz_mode if global_viz_mode is not None else current["global_viz_mode"]
+ vmodes = viz_modes if viz_modes is not None else current["viz_modes"]
+ loc = locale if locale is not None else current.get("locale", "nl")
+ if loc not in ("nl", "en"):
+ loc = "nl"
+ fetch_one(
+ """
+ INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode, viz_modes, locale, updated_at)
+ VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, NOW())
+ ON CONFLICT (user_key) DO UPDATE SET
+ dashboard_layout = EXCLUDED.dashboard_layout,
+ global_viz_mode = EXCLUDED.global_viz_mode,
+ viz_modes = EXCLUDED.viz_modes,
+ locale = EXCLUDED.locale,
+ updated_at = NOW()
+ RETURNING user_key
+ """,
+ (user_key, json.dumps(layout), gviz, json.dumps(vmodes), loc),
+ )
+ return get_preferences(user_key)
diff --git a/cockpit/static/css/agent-characters.css b/cockpit/static/css/agent-characters.css
new file mode 100644
index 0000000..dff29b2
--- /dev/null
+++ b/cockpit/static/css/agent-characters.css
@@ -0,0 +1,210 @@
+/* 3D agent poppetjes — full-width portrait met headset */
+.agent-soul-card {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0 !important;
+}
+.agent-soul-card > *:not(.agent-char-wrap) {
+ padding-left: 0.85rem;
+ padding-right: 0.85rem;
+}
+.agent-soul-card > strong { padding-top: 0.65rem; }
+.agent-soul-card > .agent-meta-row { padding-bottom: 0.75rem; }
+
+.agent-char-wrap {
+ position: relative;
+ width: 100%;
+ height: 132px;
+ flex-shrink: 0;
+ overflow: hidden;
+ background: linear-gradient(160deg, #0c1929 0%, #152238 50%, #0a1020 100%);
+ border-bottom: 1px solid rgba(56, 189, 248, 0.15);
+}
+.agent-char-wrap.large {
+ width: 120px;
+ height: 148px;
+ border-radius: 14px;
+ border: 2px solid rgba(56, 189, 248, 0.25);
+}
+.agent-char-wrap img.agent-char-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center 15%;
+ display: block;
+}
+.agent-char-svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+.agent-char-portrait {
+ width: 100%;
+ height: 100%;
+}
+
+.agent-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.68rem;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ color: #cbd5e1;
+ max-width: 100%;
+ margin-top: 0.25rem;
+}
+.agent-status-pill .dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.agent-status-pill.busy .dot { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
+.agent-status-pill.active .dot { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
+.agent-status-pill.idle .dot { background: #64748b; }
+.agent-status-pill.error .dot { background: #ef4444; }
+.agent-current-task {
+ font-size: 0.75rem;
+ color: #94a3b8;
+ line-height: 1.4;
+ margin: 0.35rem 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.agent-role-desc {
+ font-size: 0.72rem;
+ color: #64748b;
+ line-height: 1.35;
+ margin: 0.25rem 0;
+}
+.agents-neo-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.85rem;
+}
+.agent-soul-card.selected {
+ outline: 2px solid #ffd700;
+ outline-offset: -2px;
+}
+.agents-viz-compact.page-viz-panel { min-height: 200px; }
+.agents-viz-compact .page-viz-body { min-height: 160px; }
+.agents-viz-compact .page-viz-canvas { min-height: 140px; max-height: 180px; }
+.agents-viz-compact.viz-mode-rings .page-viz-canvas { min-height: 120px; max-height: 160px; }
+.agents-viz-compact.viz-mode-rings .page-viz-body { min-height: 140px; }
+
+.agent-events-link {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ font-size: 0.72rem;
+ color: #38bdf8;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.agent-events-link:hover { color: #7dd3fc; }
+
+.agent-event-clickable {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem 0.65rem;
+ width: 100%;
+ text-align: left;
+ background: rgba(15, 23, 42, 0.45);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 8px;
+ padding: 0.55rem 0.7rem;
+ margin-bottom: 0.4rem;
+ cursor: pointer;
+ color: inherit;
+}
+.agent-event-clickable:hover {
+ border-color: rgba(56, 189, 248, 0.35);
+ background: rgba(30, 58, 95, 0.35);
+}
+.agent-event-time { font-size: 0.7rem; color: #64748b; }
+.agent-event-title { flex: 1; font-size: 0.82rem; }
+
+.agent-events-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 1200;
+}
+.agent-events-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: min(420px, 92vw);
+ height: 100vh;
+ background: #0f172a;
+ border-left: 1px solid rgba(56, 189, 248, 0.2);
+ z-index: 1201;
+ display: flex;
+ flex-direction: column;
+ transform: translateX(100%);
+ transition: transform 0.22s ease;
+ box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45);
+}
+.agent-events-drawer.open { transform: translateX(0); }
+.agent-events-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 1rem 1.1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+}
+.agent-events-head h3 { margin: 0; font-size: 1.05rem; }
+.agent-events-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem;
+}
+.agent-event-card {
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 10px;
+ padding: 0.7rem 0.8rem;
+ margin-bottom: 0.55rem;
+ cursor: pointer;
+ background: rgba(15, 23, 42, 0.6);
+ transition: border-color 0.15s;
+}
+.agent-event-card:hover,
+.agent-event-card.expanded {
+ border-color: rgba(56, 189, 248, 0.35);
+}
+.agent-event-card-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.35rem;
+}
+.agent-event-card time { font-size: 0.7rem; color: #64748b; }
+.agent-event-card strong { display: block; font-size: 0.88rem; line-height: 1.35; }
+.agent-event-meta {
+ font-size: 0.68rem;
+ color: #64748b;
+ margin-top: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+.agent-event-detail {
+ margin-top: 0.65rem;
+ padding-top: 0.65rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.12);
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: #cbd5e1;
+ white-space: pre-wrap;
+}
+.badge-sm { font-size: 0.62rem; padding: 0.12rem 0.4rem; }
diff --git a/cockpit/static/css/agents-mesh.css b/cockpit/static/css/agents-mesh.css
index 6846d6e..ead1d41 100644
--- a/cockpit/static/css/agents-mesh.css
+++ b/cockpit/static/css/agents-mesh.css
@@ -58,6 +58,23 @@
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
}
+.mesh-exec circle.main {
+ fill: #0f172a;
+ stroke-width: 3;
+}
+
+.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
+.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
+
+.mesh-exec circle.ring {
+ fill: none;
+ stroke: rgba(255, 255, 255, 0.15);
+ stroke-width: 1.5;
+ stroke-dasharray: 4 4;
+}
+
+.mesh-agent .mesh-status { fill: #64748b; }
+
.mesh-herman circle.ring {
fill: none;
stroke: rgba(255, 215, 0, 0.35);
@@ -80,3 +97,26 @@
color: #94a3b8;
font-size: 0.82rem;
}
+
+.vtab-badge {
+ display: inline-flex;
+ min-width: 1.1rem;
+ height: 1.1rem;
+ padding: 0 0.35rem;
+ margin-left: 0.35rem;
+ border-radius: 999px;
+ background: #f59e0b;
+ color: #0a0e14;
+ font-size: 0.65rem;
+ font-weight: 700;
+ align-items: center;
+ justify-content: center;
+}
+
+.badge-warn {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+}
diff --git a/cockpit/static/css/clients-crm.css b/cockpit/static/css/clients-crm.css
new file mode 100644
index 0000000..c65dfc0
--- /dev/null
+++ b/cockpit/static/css/clients-crm.css
@@ -0,0 +1,93 @@
+.clients-shell { max-width: 1400px; }
+.clients-kpi-row {
+ display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; margin: 1rem 0;
+}
+@media (max-width: 1000px) { .clients-kpi-row { grid-template-columns: repeat(2, 1fr); } }
+.clients-kpi {
+ background: rgba(13,18,25,0.85); border: 1px solid rgba(148,163,184,0.15);
+ border-radius: 12px; padding: 0.85rem 1rem;
+}
+.clients-kpi span { display: block; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; }
+.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
+
+.clients-tabs {
+ display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
+ margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
+}
+.clients-tab {
+ border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
+ color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
+}
+.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
+.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
+
+.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
+@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
+.clients-kanban-col {
+ background: rgba(13,18,25,0.7); border: 1px solid rgba(148,163,184,0.12);
+ border-radius: 12px; padding: 0.65rem; min-height: 320px;
+}
+.clients-kanban-col h3 {
+ margin: 0 0 0.65rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: #94a3b8;
+ display: flex; justify-content: space-between; align-items: center;
+}
+.clients-kanban-col h3 .count { background: rgba(56,189,248,0.15); color: #38bdf8; border-radius: 999px; padding: 0.1rem 0.45rem; font-size: 0.65rem; }
+.clients-card {
+ background: rgba(0,0,0,0.35); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px;
+ padding: 0.7rem; margin-bottom: 0.5rem; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
+}
+.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
+.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
+.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
+.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
+.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
+.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
+.stage-intake { border-left: 3px solid #94a3b8; }
+.stage-discovery { border-left: 3px solid #38bdf8; }
+.stage-proposal { border-left: 3px solid #f59e0b; }
+.stage-active { border-left: 3px solid #22c55e; }
+.stage-churned { border-left: 3px solid #64748b; opacity: 0.75; }
+
+.clients-toolbar {
+ display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; align-items: center;
+}
+.clients-toolbar input, .clients-toolbar select {
+ min-width: 160px; background: rgba(0,0,0,0.35); border: 1px solid rgba(148,163,184,0.2);
+ color: #e2e8f0; border-radius: 8px; padding: 0.45rem 0.65rem; font-size: 0.82rem;
+}
+
+.clients-drawer {
+ position: fixed; top: 0; right: 0; width: min(480px, 96vw); height: 100vh;
+ background: #0d1219; border-left: 1px solid rgba(148,163,184,0.15);
+ z-index: 1200; transform: translateX(105%); transition: transform 0.25s ease;
+ overflow-y: auto; padding: 1.25rem;
+}
+.clients-drawer.open { transform: translateX(0); }
+.clients-drawer-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
+.clients-drawer-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
+.clients-drawer-section { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(148,163,184,0.1); }
+.clients-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; }
+
+.clients-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.65rem; }
+.clients-form-grid .full { grid-column: 1 / -1; }
+@media (max-width: 600px) { .clients-form-grid { grid-template-columns: 1fr; } }
+
+.store-search-panel {
+ display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
+}
+@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
+.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
+.store-list-item {
+ padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
+}
+.store-list-item:hover, .store-list-item.selected { background: rgba(56,189,248,0.08); }
+.store-list-item.selected { border-left: 3px solid #38bdf8; }
+.store-edit-panel {
+ background: rgba(13,18,25,0.7); border: 1px solid rgba(148,163,184,0.12);
+ border-radius: 12px; padding: 1rem;
+}
+.store-edit-panel h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f1f5f9; }
+.store-checks { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.5rem 0; }
+.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
+
+.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
diff --git a/cockpit/static/css/herman-dashboard.css b/cockpit/static/css/herman-dashboard.css
index da1e999..f803724 100644
--- a/cockpit/static/css/herman-dashboard.css
+++ b/cockpit/static/css/herman-dashboard.css
@@ -179,7 +179,27 @@
}
@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; }
+.hm-chart-wrap { position: relative; height: 200px; width: 100%; }
+.hm-chart-wrap canvas { display: block; width: 100% !important; height: 100% !important; }
+
+/* Page visualisatie — vult panel volledig */
+.page-viz-panel { min-height: 360px; display: flex; flex-direction: column; }
+.page-viz-panel .page-viz-body { flex: 1; display: flex; flex-direction: column; min-height: 300px; }
+.page-viz-panel .hm-chart-wrap.page-viz-canvas {
+ flex: 1; min-height: 300px; height: auto; width: 100%;
+}
+.page-viz-panel.viz-mode-chart .viz-alt { display: none !important; }
+.page-viz-panel.viz-mode-alt .page-viz-canvas { display: none !important; }
+.page-viz-panel.viz-mode-alt .viz-alt {
+ flex: 1; display: flex; flex-direction: column; justify-content: stretch;
+ min-height: 300px; padding: 0.25rem 0;
+}
+.page-viz-panel.viz-mode-alt .viz-cards {
+ flex: 1; align-content: start; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.65rem;
+}
+.page-viz-panel.viz-mode-alt .viz-table { width: 100%; font-size: 0.82rem; }
+.page-viz-panel.viz-mode-alt .viz-eq { flex: 1; min-height: 140px; max-height: 220px; margin-top: 0.75rem; }
+.page-viz-panel.viz-mode-alt .viz-eq-row { font-size: 0.82rem; padding: 0.15rem 0; }
.briefing-loading-overlay {
position: absolute; inset: 0; background: rgba(10, 14, 20, 0.8);
@@ -252,6 +272,64 @@
.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-eq-purple span { background: linear-gradient(to top, rgba(168,85,247,0.3), #a855f7); box-shadow: 0 0 6px rgba(168,85,247,0.35); }
+.hm-eq-cyan span { background: linear-gradient(to top, rgba(56,189,248,0.3), #38bdf8); box-shadow: 0 0 6px rgba(56,189,248,0.35); }
+.hm-eq-lime span { background: linear-gradient(to top, rgba(184,255,60,0.25), #b8ff3c); box-shadow: 0 0 6px rgba(184,255,60,0.35); }
+.hm-hero-links .beurs-mini-chip { font-size: 0.75rem; }
+.hm-dash-analytics .hm-analytics-panel { min-height: 320px; }
+.hm-dash-analytics .page-viz-body { min-height: 260px; }
+.hm-dash-analytics .page-viz-canvas { min-height: 260px; }
+.hm-dash-two-col .page-viz-panel { min-height: 340px; }
+.hm-trend-list li { margin-bottom: 0.35rem; }
+
+.hm-widget-draggable { cursor: grab; position: relative; }
+.hm-widget-draggable:active { cursor: grabbing; }
+.hm-widget-dragging { opacity: 0.75; outline: 2px dashed var(--hm-cyan); }
+.hm-widget-handle {
+ font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase;
+ color: #64748b; margin-bottom: 0.35rem; user-select: none;
+}
+.hm-widget-handle::before { content: '⋮⋮ '; color: var(--hm-cyan); }
+
+.viz-toolbar {
+ display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
+ margin-bottom: 1rem; padding: 0.65rem 0.85rem;
+ background: rgba(13,18,25,0.8); border: 1px solid var(--hm-border); border-radius: 12px;
+}
+.viz-global-label { font-size: 0.72rem; color: #94a3b8; margin-right: 0.35rem; }
+.viz-mode-btn {
+ border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
+ color: #cbd5e1; border-radius: 999px; padding: 0.25rem 0.65rem; font-size: 0.72rem; cursor: pointer;
+}
+.viz-mode-btn.active { border-color: var(--hm-cyan); color: var(--hm-cyan); box-shadow: 0 0 12px rgba(0,229,255,0.15); }
+.viz-alt { min-height: 140px; }
+.viz-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.5rem; }
+.viz-card { background: rgba(0,0,0,0.25); border: 1px solid var(--hm-border); border-radius: 10px; padding: 0.65rem; text-align: center; }
+.viz-card-label { display: block; font-size: 0.65rem; color: #94a3b8; text-transform: uppercase; }
+.viz-card-val { font-size: 1.25rem; color: #f1f5f9; }
+.viz-eq-row { display: flex; justify-content: space-between; font-size: 0.78rem; margin-bottom: 0.25rem; color: #cbd5e1; }
+.viz-table { font-size: 0.78rem; }
+.approval-dash-row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
+.approval-row-scan td { background: rgba(245, 158, 11, 0.06); }
+.approval-row-scan td:first-child { border-left: 3px solid #f59e0b; }
+
+.global-viz-toolbar {
+ display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
+ margin: 0 auto; max-width: min(520px, 55vw);
+}
+.global-viz-btn {
+ border: 1px solid rgba(148,163,184,0.18); background: rgba(0,0,0,0.35);
+ color: #94a3b8; border-radius: 999px; padding: 0.15rem 0.45rem; font-size: 0.62rem;
+ cursor: pointer; white-space: nowrap;
+}
+.global-viz-btn.active {
+ border-color: var(--hm-cyan, #00e5ff); color: var(--hm-cyan, #00e5ff);
+ box-shadow: 0 0 10px rgba(0,229,255,0.12);
+}
+@media (max-width: 900px) {
+ .global-viz-toolbar { display: none; }
+ .app-topbar .page-tip { display: none; }
+}
.hm-highlight-scroll { max-height: 220px; overflow-y: auto; }
.hm-highlight-item {
diff --git a/cockpit/static/css/ops-topology.css b/cockpit/static/css/ops-topology.css
index 6f53a14..69c1a3c 100644
--- a/cockpit/static/css/ops-topology.css
+++ b/cockpit/static/css/ops-topology.css
@@ -9,122 +9,175 @@
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
+ padding: 1.25rem 1.5rem;
+ margin-bottom: 0;
}
-.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;
-}
+.ops-header h1 { margin: 0; font-size: 1.55rem; }
.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));
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ background: radial-gradient(circle at 50% 20%, rgba(0, 229, 255, 0.08), rgba(8, 15, 29, 0.95));
overflow: hidden;
+ padding: 0.5rem;
}
.topology-canvas {
width: 100%;
- min-height: 520px;
+ min-height: 560px;
+ display: block;
}
-.topology-lines line {
- stroke: rgba(59, 130, 246, 0.42);
+.ops-layer-line {
+ stroke: rgba(148, 163, 184, 0.12);
+ stroke-width: 1;
+ stroke-dasharray: 6 8;
+}
+
+.ops-layer-label {
+ fill: #64748b;
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ font-weight: 600;
+}
+
+.topology-line {
+ stroke: rgba(59, 130, 246, 0.35);
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));
+ stroke: rgba(0, 229, 255, 0.95);
+ stroke-width: 2.5;
+ stroke-dasharray: 8 16;
+ animation: ops-line-pulse 1.6s linear infinite;
+ filter: drop-shadow(0 0 6px rgba(0, 229, 255, 0.8));
}
-.ops-node circle {
+.ops-node-circle {
fill: #0f172a;
- stroke-width: 2.2;
+ stroke-width: 2.5;
}
-.ops-node text {
- fill: #d9e8ff;
- font-size: 12px;
+.ops-node-title {
+ fill: #e2e8f0;
+ font-size: 11px;
+ font-weight: 700;
+ text-anchor: middle;
+}
+
+.ops-node-sub {
+ fill: #94a3b8;
+ font-size: 9px;
+ text-anchor: middle;
+ letter-spacing: 0.06em;
+}
+
+.ops-node-agent {
+ fill: #ffd700;
+ font-size: 9px;
+ text-anchor: middle;
font-weight: 600;
}
-.ops-node .sub {
- fill: #8ea7ca;
- font-size: 11px;
- font-weight: 500;
+.ops-node-role {
+ fill: #64748b;
+ font-size: 8px;
+ text-anchor: middle;
}
-.node-proxmox circle {
+.node-proxmox .ops-node-circle {
stroke: #f59e0b;
- filter: drop-shadow(0 0 11px rgba(245, 158, 11, 0.6));
+ filter: drop-shadow(0 0 12px rgba(245, 158, 11, 0.55));
}
-.node-vm circle {
+.node-vm .ops-node-circle {
stroke: #38bdf8;
- filter: drop-shadow(0 0 9px rgba(56, 189, 248, 0.5));
+ filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.45));
}
-.node-service circle {
+.node-service .ops-node-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-online .ops-node-circle { stroke: #22c55e; }
+.status-offline .ops-node-circle { stroke: #ef4444; filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.5)); }
+.status-degraded .ops-node-circle { stroke: #f59e0b; }
+
+.ops-monitor-box {
+ fill: rgba(13, 18, 25, 0.92);
+ stroke: rgba(0, 229, 255, 0.35);
+ stroke-width: 1.5;
}
-.status-degraded circle,
-.status-unknown circle {
- stroke: #f59e0b;
+.ops-monitor.status-healthy .ops-monitor-box { stroke: #22c55e; }
+.ops-monitor.status-warn .ops-monitor-box { stroke: #f59e0b; }
+.ops-monitor.status-idle .ops-monitor-box { stroke: #38bdf8; }
+.ops-monitor.status-offline .ops-monitor-box { stroke: #64748b; }
+
+.ops-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.78rem;
+ color: #94a3b8;
}
+.ops-legend .dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 0.35rem;
+}
+
+.dot-online { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
+.dot-offline { background: #ef4444; }
+.dot-degraded { background: #f59e0b; }
+
.ops-meta {
color: #8ba3c4;
font-size: 0.85rem;
}
-@keyframes ops-line-pulse {
- from {
- stroke-dashoffset: 26;
- }
- to {
- stroke-dashoffset: 0;
- }
+.ops-panels-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+@media (max-width: 900px) { .ops-panels-grid { grid-template-columns: 1fr; } }
+
+.ops-side-panel h2 { margin: 0 0 0.35rem; font-size: 1rem; }
+.ops-maint-list { display: grid; gap: 0.5rem; max-height: 220px; overflow-y: auto; margin-top: 0.5rem; }
+.ops-maint-item {
+ border: 1px solid rgba(148,163,184,0.15); border-radius: 10px; padding: 0.55rem 0.65rem;
+ background: rgba(0,0,0,0.2); font-size: 0.82rem;
+}
+.ops-maint-item strong { display: block; color: #e2e8f0; }
+.ops-maint-item small { color: #64748b; font-size: 0.72rem; }
+.ops-maint-item p { margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.78rem; }
+.ops-maint-item.sev-critical { border-color: rgba(239,68,68,0.45); }
+.ops-maint-item.sev-warning { border-color: rgba(245,158,11,0.4); }
+.ops-maint-item.sev-info { border-color: rgba(56,189,248,0.25); }
+
+.ops-hardware-box {
+ fill: rgba(13, 18, 25, 0.95);
+ stroke: rgba(245, 158, 11, 0.55);
+ stroke-width: 2;
+ filter: drop-shadow(0 0 14px rgba(245, 158, 11, 0.25));
+}
+.ops-hardware-slot { fill: rgba(30, 41, 59, 0.9); stroke: rgba(148,163,184,0.2); }
+.node-hardware.status-online .ops-hardware-box { stroke: #22c55e; }
+.ops-title-left { text-anchor: start; }
+
+.topology-canvas a { cursor: pointer; }
+.topology-canvas a:hover .ops-node-circle { stroke-width: 3.5; }
+
+@keyframes ops-line-pulse {
+ from { stroke-dashoffset: 24; }
+ to { stroke-dashoffset: 0; }
}
diff --git a/cockpit/static/css/packaging-studio.css b/cockpit/static/css/packaging-studio.css
new file mode 100644
index 0000000..09c773b
--- /dev/null
+++ b/cockpit/static/css/packaging-studio.css
@@ -0,0 +1,205 @@
+.pack-studio {
+ --pk-accent: #00e5ff;
+ --pk-gold: #ffd700;
+ --pk-panel: rgba(12, 20, 36, 0.92);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+.pack-hero {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ flex-wrap: wrap;
+ padding: 1.1rem 1.25rem;
+ border-radius: 14px;
+ background: linear-gradient(135deg, rgba(0, 229, 255, 0.1), rgba(255, 215, 0, 0.06));
+ border: 1px solid rgba(0, 229, 255, 0.22);
+}
+.pack-hero h1 { margin: 0; font-size: 1.45rem; }
+.pack-hero p { margin: 0.35rem 0 0; color: #94a3b8; max-width: 52ch; }
+.pack-toolbar {
+ display: flex;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.pack-layout {
+ display: grid;
+ grid-template-columns: 320px 1fr 280px;
+ gap: 1rem;
+ align-items: start;
+}
+@media (max-width: 1200px) {
+ .pack-layout { grid-template-columns: 1fr; }
+}
+.pack-panel {
+ background: var(--pk-panel);
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 14px;
+ padding: 1rem;
+}
+.pack-panel h3 {
+ margin: 0 0 0.75rem;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #64748b;
+}
+.pack-tabs {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.85rem;
+}
+.pack-tab {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ background: rgba(0, 0, 0, 0.25);
+ color: #cbd5e1;
+ border-radius: 999px;
+ padding: 0.28rem 0.7rem;
+ font-size: 0.72rem;
+ cursor: pointer;
+}
+.pack-tab.active {
+ border-color: var(--pk-accent);
+ color: var(--pk-accent);
+ background: rgba(0, 229, 255, 0.1);
+}
+.pack-field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.6rem;
+}
+.pack-field-grid label,
+.pack-panel label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.72rem;
+ color: #94a3b8;
+}
+.pack-field-grid .full { grid-column: 1 / -1; }
+.pack-elements {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.4rem;
+}
+.pack-elements label {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.4rem 0.5rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.1);
+ background: rgba(0, 0, 0, 0.2);
+ cursor: pointer;
+ font-size: 0.75rem;
+ color: #e2e8f0;
+}
+.pack-color-row {
+ display: grid;
+ grid-template-columns: 1fr 44px;
+ gap: 0.4rem;
+ align-items: end;
+}
+.pack-color-row input[type="color"] {
+ width: 44px;
+ height: 34px;
+ border: none;
+ border-radius: 8px;
+ padding: 0;
+ cursor: pointer;
+}
+.pack-preview-wrap {
+ min-height: 520px;
+ display: flex;
+ flex-direction: column;
+}
+.pack-preview-stage {
+ flex: 1;
+ min-height: 460px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background:
+ radial-gradient(circle at 30% 20%, rgba(0, 229, 255, 0.08), transparent 45%),
+ radial-gradient(circle at 80% 70%, rgba(255, 215, 0, 0.06), transparent 40%),
+ rgba(0, 0, 0, 0.35);
+ border: 1px solid rgba(0, 229, 255, 0.18);
+ border-radius: 12px;
+ overflow: auto;
+ padding: 1rem;
+}
+.pack-preview-stage svg {
+ width: 100%;
+ max-height: 70vh;
+ filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
+}
+.pack-meta-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.65rem;
+ font-size: 0.72rem;
+ color: #64748b;
+}
+.pack-library {
+ max-height: 72vh;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+}
+.pack-design-card {
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 10px;
+ padding: 0.65rem;
+ background: rgba(0, 0, 0, 0.28);
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+.pack-design-card:hover,
+.pack-design-card.active {
+ border-color: rgba(0, 229, 255, 0.4);
+}
+.pack-design-card strong { font-size: 0.82rem; display: block; }
+.pack-design-card small { color: #64748b; display: block; margin-top: 0.2rem; font-size: 0.68rem; }
+.pack-nas-badge {
+ display: inline-block;
+ margin-top: 0.35rem;
+ font-size: 0.62rem;
+ padding: 0.15rem 0.45rem;
+ border-radius: 999px;
+ background: rgba(34, 197, 94, 0.15);
+ color: #86efac;
+ border: 1px solid rgba(34, 197, 94, 0.25);
+}
+.pack-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 1300;
+}
+.pack-modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: min(440px, 92vw);
+ background: #0f172a;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ border-radius: 14px;
+ padding: 1.1rem;
+ z-index: 1301;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
+}
+.pack-modal h3 { margin: 0 0 0.75rem; }
+.pack-modal-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1rem;
+}
diff --git a/cockpit/static/css/projects-hub.css b/cockpit/static/css/projects-hub.css
new file mode 100644
index 0000000..44183db
--- /dev/null
+++ b/cockpit/static/css/projects-hub.css
@@ -0,0 +1,63 @@
+.project-type-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.55rem;
+ margin: 0.75rem 0;
+}
+.project-type-card {
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 12px;
+ padding: 0.65rem;
+ text-align: center;
+ cursor: pointer;
+ background: rgba(0, 0, 0, 0.25);
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+.project-type-card:hover { border-color: rgba(56, 189, 248, 0.4); }
+.project-type-card.selected {
+ border-color: #38bdf8;
+ box-shadow: 0 0 16px rgba(56, 189, 248, 0.15);
+ background: rgba(56, 189, 248, 0.08);
+}
+.project-type-card .icon { font-size: 1.5rem; display: block; margin-bottom: 0.25rem; }
+.project-type-card .label { font-size: 0.72rem; color: #cbd5e1; line-height: 1.3; }
+.nas-path-box {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.72rem;
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ border-radius: 8px;
+ padding: 0.5rem 0.65rem;
+ color: #7dd3fc;
+ word-break: break-all;
+ margin: 0.5rem 0;
+}
+.asset-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.65rem;
+}
+.asset-card {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 10px;
+ padding: 0.65rem;
+}
+.asset-card .type { font-size: 0.65rem; text-transform: uppercase; color: #64748b; }
+.photo-pick-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
+ gap: 0.4rem;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.photo-pick-item {
+ aspect-ratio: 1;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 2px solid transparent;
+ cursor: pointer;
+ background: #111;
+}
+.photo-pick-item.selected { border-color: #38bdf8; }
+.photo-pick-item img { width: 100%; height: 100%; object-fit: cover; }
diff --git a/cockpit/static/i18n/en.json b/cockpit/static/i18n/en.json
new file mode 100644
index 0000000..d187741
--- /dev/null
+++ b/cockpit/static/i18n/en.json
@@ -0,0 +1,118 @@
+{
+ "nav": {
+ "herman": "Herman",
+ "crm": "CRM",
+ "clients": "Clients",
+ "deals": "Deals",
+ "products": "Products",
+ "suppliers": "Suppliers",
+ "intel": "Intel",
+ "beurs": "Market Live",
+ "retail": "Retail",
+ "browser": "Browser",
+ "documents": "Documents",
+ "projects": "Projects",
+ "hermes": "Hermes",
+ "telegram": "Telegram",
+ "agents": "Agents",
+ "marketing": "Marketing",
+ "chat": "Chat",
+ "studio": "Studio",
+ "packaging": "Packaging",
+ "reports": "Reports",
+ "system": "System",
+ "analytics": "Analytics",
+ "voice": "Voice",
+ "ops": "IT Ops",
+ "settings": "Settings",
+ "gitea": "Gitea"
+ },
+ "common": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "edit": "Edit",
+ "add": "Add",
+ "search": "Search",
+ "refresh": "Refresh",
+ "loading": "Loading…",
+ "none": "—",
+ "yes": "Yes",
+ "no": "No",
+ "confirm_delete": "Are you sure you want to delete this?",
+ "saved": "Saved",
+ "deleted": "Deleted",
+ "error": "Error",
+ "all": "All",
+ "actions": "Actions",
+ "language": "Language",
+ "dutch": "Nederlands",
+ "english": "English"
+ },
+ "clients": {
+ "title": "CRM · Clients",
+ "subtitle": "Manage clients · pipeline · link stores · enrich branch data",
+ "new": "New client",
+ "tab_kanban": "Kanban",
+ "tab_list": "List",
+ "tab_stores": "Supermarkets"
+ },
+ "products": {
+ "title": "Product catalog",
+ "subtitle": "SKUs · margins · client links · launch planning",
+ "new": "New product",
+ "tab_grid": "Overview",
+ "tab_list": "List",
+ "tab_margin": "Margin calculator",
+ "total": "Total products",
+ "active": "Active",
+ "avg_margin": "Avg. margin",
+ "with_client": "With client",
+ "name": "Product name",
+ "status": "Status",
+ "margin_pct": "Margin %",
+ "moq": "MOQ",
+ "shelf_target": "Shelf / listing",
+ "launch_date": "Launch date",
+ "client": "Client",
+ "status_concept": "Concept",
+ "status_development": "Development",
+ "status_active": "Active",
+ "status_discontinued": "Discontinued",
+ "calc_cost": "Cost price",
+ "calc_sell": "Sell price",
+ "calc_profit": "Profit",
+ "calc_margin": "Margin",
+ "calc_run": "Calculate",
+ "no_products": "No products yet — add your first SKU.",
+ "drawer_deals": "Linked client"
+ },
+ "suppliers": {
+ "title": "Suppliers",
+ "subtitle": "Vendor registry · ratings · lead times · contacts",
+ "new": "New supplier",
+ "tab_grid": "Overview",
+ "tab_list": "List",
+ "total": "Total suppliers",
+ "avg_rating": "Avg. rating",
+ "countries": "Countries",
+ "avg_lead": "Avg. lead time",
+ "name": "Company name",
+ "country": "Country",
+ "category": "Category",
+ "rating": "Rating",
+ "moq": "MOQ",
+ "lead_time": "Lead time (days)",
+ "contact": "Contact",
+ "cat_meat": "Meat",
+ "cat_dairy": "Dairy",
+ "cat_spices": "Spices",
+ "cat_packaging": "Packaging",
+ "cat_logistics": "Logistics",
+ "cat_ingredients": "Ingredients",
+ "cat_other": "Other",
+ "no_suppliers": "No suppliers registered yet.",
+ "filter_country": "Filter country",
+ "filter_category": "Filter category"
+ }
+}
diff --git a/cockpit/static/i18n/nl.json b/cockpit/static/i18n/nl.json
new file mode 100644
index 0000000..670c424
--- /dev/null
+++ b/cockpit/static/i18n/nl.json
@@ -0,0 +1,118 @@
+{
+ "nav": {
+ "herman": "Herman",
+ "crm": "CRM",
+ "clients": "Klanten",
+ "deals": "Deals",
+ "products": "Producten",
+ "suppliers": "Leveranciers",
+ "intel": "Intel",
+ "beurs": "Beurs Live",
+ "retail": "Retail",
+ "browser": "Browser",
+ "documents": "Documenten",
+ "projects": "Projecten",
+ "hermes": "Hermes",
+ "telegram": "Telegram",
+ "agents": "Agents",
+ "marketing": "Marketing",
+ "chat": "Chat",
+ "studio": "Studio",
+ "packaging": "Packaging",
+ "reports": "Rapporten",
+ "system": "Systeem",
+ "analytics": "Analytics",
+ "voice": "Voice",
+ "ops": "IT Ops",
+ "settings": "Instellingen",
+ "gitea": "Gitea"
+ },
+ "common": {
+ "save": "Opslaan",
+ "cancel": "Annuleren",
+ "delete": "Verwijderen",
+ "edit": "Bewerken",
+ "add": "Toevoegen",
+ "search": "Zoeken",
+ "refresh": "Verversen",
+ "loading": "Laden…",
+ "none": "—",
+ "yes": "Ja",
+ "no": "Nee",
+ "confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
+ "saved": "Opgeslagen",
+ "deleted": "Verwijderd",
+ "error": "Fout",
+ "all": "Alle",
+ "actions": "Acties",
+ "language": "Taal",
+ "dutch": "Nederlands",
+ "english": "English"
+ },
+ "clients": {
+ "title": "CRM · Klanten",
+ "subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
+ "new": "Nieuwe klant",
+ "tab_kanban": "Kanban",
+ "tab_list": "Lijst",
+ "tab_stores": "Supermarkten"
+ },
+ "products": {
+ "title": "Productcatalogus",
+ "subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
+ "new": "Nieuw product",
+ "tab_grid": "Overzicht",
+ "tab_list": "Lijst",
+ "tab_margin": "Marge calculator",
+ "total": "Totaal producten",
+ "active": "Actief",
+ "avg_margin": "Gem. marge",
+ "with_client": "Met klant",
+ "name": "Productnaam",
+ "status": "Status",
+ "margin_pct": "Marge %",
+ "moq": "MOQ",
+ "shelf_target": "Schap / listing",
+ "launch_date": "Launch datum",
+ "client": "Klant",
+ "status_concept": "Concept",
+ "status_development": "Ontwikkeling",
+ "status_active": "Actief",
+ "status_discontinued": "Stopgezet",
+ "calc_cost": "Inkoopprijs",
+ "calc_sell": "Verkoopprijs",
+ "calc_profit": "Winst",
+ "calc_margin": "Marge",
+ "calc_run": "Berekenen",
+ "no_products": "Nog geen producten — voeg je eerste SKU toe.",
+ "drawer_deals": "Gekoppelde klant"
+ },
+ "suppliers": {
+ "title": "Leveranciers",
+ "subtitle": "Vendor registry · ratings · levertijden · contacten",
+ "new": "Nieuwe leverancier",
+ "tab_grid": "Overzicht",
+ "tab_list": "Lijst",
+ "total": "Totaal leveranciers",
+ "avg_rating": "Gem. rating",
+ "countries": "Landen",
+ "avg_lead": "Gem. levertijd",
+ "name": "Bedrijfsnaam",
+ "country": "Land",
+ "category": "Categorie",
+ "rating": "Rating",
+ "moq": "MOQ",
+ "lead_time": "Levertijd (dagen)",
+ "contact": "Contact",
+ "cat_meat": "Vlees",
+ "cat_dairy": "Zuivel",
+ "cat_spices": "Kruiden & specerijen",
+ "cat_packaging": "Verpakking",
+ "cat_logistics": "Logistiek",
+ "cat_ingredients": "Ingrediënten",
+ "cat_other": "Overig",
+ "no_suppliers": "Nog geen leveranciers geregistreerd.",
+ "filter_country": "Filter land",
+ "filter_category": "Filter categorie"
+ }
+}
diff --git a/cockpit/static/img/agents/sysops.png b/cockpit/static/img/agents/sysops.png
new file mode 100644
index 0000000..9da40b4
Binary files /dev/null and b/cockpit/static/img/agents/sysops.png differ
diff --git a/cockpit/static/js/agent-avatars.js b/cockpit/static/js/agent-avatars.js
new file mode 100644
index 0000000..7430891
--- /dev/null
+++ b/cockpit/static/js/agent-avatars.js
@@ -0,0 +1,109 @@
+/** 3D-style agent poppetjes met headset — consistent team look */
+window.AgentAvatars = (function () {
+ var CFG = {
+ herman: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'exec', accent: '#ffd700' },
+ marketing: { hoodie: '#9d174d', hair: '#1f2937', skin: '#f5c9a8', style: 'bob', accent: '#ff6b9d' },
+ bizdev: { hoodie: '#0e4d6e', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#00e5ff' },
+ finance: { hoodie: '#14532d', hair: '#3d2314', skin: '#e8b896', style: 'glasses', accent: '#22c55e' },
+ sourcing: { hoodie: '#9a3412', hair: '#1a1a1a', skin: '#c9a07a', style: 'short', accent: '#f97316' },
+ product: { hoodie: '#581c87', hair: '#4a3728', skin: '#f5c9a8', style: 'curl', accent: '#a855f7' },
+ halal: { hoodie: '#065f46', hair: '#1f2937', skin: '#d4a574', style: 'short', accent: '#34d399' },
+ design: { hoodie: '#831843', hair: '#111827', skin: '#f5c9a8', style: 'long', accent: '#ec4899' },
+ knowledge: { hoodie: '#312e81', hair: '#374151', skin: '#e8b896', style: 'glasses', accent: '#6366f1' },
+ retail: { hoodie: '#365314', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#b8ff3c' },
+ browser: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#c9a07a', style: 'short', accent: '#38bdf8' },
+ hermes: { hoodie: '#1e40af', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#2aabee' },
+ email: { hoodie: '#334155', hair: '#4a3728', skin: '#ddb896', style: 'short', accent: '#94a3b8' },
+ research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
+ sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
+ packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
+ };
+
+ function cfg(key, fallbackColor) {
+ var k = (key || 'agent').toLowerCase();
+ var c = CFG[k] || { hoodie: fallbackColor || '#1e3a5f', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#38bdf8' };
+ return c;
+ }
+
+ function capSvg(hid, c) {
+ return (
+ '
Geen kansen — open Retail 360
'; return; } - container.innerHTML = opps.map(function (o) { - var score = Math.round(Number(o.halal_opportunity_score) || 0); - return '' + (o.chain || '') + ' · ' + (o.name || '') + 'Geen trending — RSS ophalen in Marketing Hub
'; + return; + } + container.innerHTML = items.map(function (r) { + var when = (r.published_at || '').substring(0, 10); + return '' + + '' + (r.title || '') + '| Item | Waarde |
|---|---|
| ' + r.label + ' | ' + r.value + ' |
Animated team · duidelijke rollen · soul.md · live activiteit
+ +Nog geen events voor deze agent.
Communication mesh met Herman als centrale Co-CEO hub.
+Agents onderaan → Herman (Co-CEO) → CEO & CTO. Pulse = actieve communicatie.
Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.
+| # | Agent | Actie | Titel | Status | Tijd | |
|---|---|---|---|---|---|---|
| + | + | + | + | + | + |
+
+
+ |
+
Geen approval requests.
+CEO overzicht · klanten · briefing · retail · alles op een rij
+CEO overzicht · trending retail · briefing · platform links
+