SysOps: deploy-all — 2026-06-09 10:41 UTC

This commit is contained in:
sysops
2026-06-09 10:41:13 +00:00
parent 69fe67cc0e
commit 21ea3a2c81
82 changed files with 8906 additions and 981 deletions
+2
View File
@@ -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)
+141 -1
View File
@@ -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"))}
+2 -2
View File
@@ -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:
+133 -2
View File
@@ -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}
+4 -1
View File
@@ -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}
+201 -40
View File
@@ -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
+197
View File
@@ -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}
+67
View File
@@ -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}))
+193
View File
@@ -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
+27 -7
View File
@@ -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
+187 -11
View File
@@ -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:
+68
View File
@@ -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:
+104
View File
@@ -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)
+232
View File
@@ -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,
}
+93
View File
@@ -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}
+234
View File
@@ -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"),
)
+30 -2
View File
@@ -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
+82
View File
@@ -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)
+210
View File
@@ -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; }
+40
View File
@@ -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;
}
+93
View File
@@ -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; }
+79 -1
View File
@@ -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 {
+126 -73
View File
@@ -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; }
}
+205
View File
@@ -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;
}
+63
View File
@@ -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; }
+118
View File
@@ -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"
}
}
+118
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+109
View File
@@ -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 (
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.35"/>' +
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + c.hoodie + '"/>' +
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="#1e40af"/>' +
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="#38bdf8" stroke-width="1.2" opacity="0.5"/>' +
'<rect x="46" y="28" width="8" height="5" rx="1" fill="#38bdf8" opacity="0.85"/>'
);
}
function hairPath(style) {
if (style === 'long') return 'M28,38 Q20,18 40,14 Q60,10 72,22 Q78,32 74,48 Q70,56 62,52 Q58,38 50,36 Q42,36 38,48 Q32,52 28,38Z';
if (style === 'bob') return 'M26,40 Q24,22 42,16 Q62,14 74,28 Q76,42 70,50 Q50,54 30,48 Q24,44 26,40Z';
if (style === 'curl') return 'M28,42 Q22,24 38,16 Q52,12 68,20 Q76,30 72,44 Q66,52 54,48 Q44,52 34,46 Q26,44 28,42Z';
return 'M30,40 Q28,24 44,18 Q58,16 70,26 Q74,36 70,44 Q54,48 38,44 Q30,42 30,40Z';
}
function svgAvatar(key, color) {
var c = cfg(key, color);
var hid = 'av-' + key;
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" class="agent-char-svg" aria-hidden="true">' +
'<defs>' +
'<linearGradient id="' + hid + '-bg" x1="0%" y1="0%" x2="100%" y2="100%">' +
'<stop offset="0%" stop-color="#0c1929"/><stop offset="50%" stop-color="#152238"/><stop offset="100%" stop-color="#0a1020"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-hood" x1="30%" y1="0%" x2="70%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.hoodie + '"/><stop offset="100%" stop-color="#0a0f18"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-skin" x1="40%" y1="0%" x2="60%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#c9956e"/>' +
'</linearGradient>' +
'<radialGradient id="' + hid + '-glow" cx="75%" cy="25%" r="45%">' +
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.35"/><stop offset="100%" stop-opacity="0"/>' +
'</radialGradient>' +
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.45"/></filter>' +
'</defs>' +
'<rect width="100" height="120" fill="url(#' + hid + '-bg)"/>' +
'<ellipse cx="78" cy="28" rx="35" ry="30" fill="url(#' + hid + '-glow)"/>' +
'<ellipse cx="50" cy="100" rx="30" ry="14" fill="rgba(0,0,0,0.4)"/>' +
'<path d="M18,118 Q18,74 50,68 Q82,74 82,118Z" fill="url(#' + hid + '-hood)" filter="url(#' + hid + '-sh)"/>' +
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + c.hoodie + '" opacity="0.9"/>' +
'<path d="M28,68 Q50,48 72,68 L68,78 Q50,72 32,78Z" fill="' + c.hoodie + '"/>' +
'<ellipse cx="50" cy="54" rx="23" ry="25" fill="url(#' + hid + '-skin)" filter="url(#' + hid + '-sh)"/>' +
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(0,0,0,0.08)"/>' +
(c.hat === 'cap' ? '' : '<path d="' + hairPath(c.style) + '" fill="' + c.hair + '"/>') +
(c.style === 'glasses'
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<line x1="50" y1="52" x2="50" y2="52" stroke="#334155" stroke-width="1.5"/>'
: '') +
'<ellipse cx="42" cy="52" rx="3" ry="3.5" fill="#1e293b"/><ellipse cx="58" cy="52" rx="3" ry="3.5" fill="#1e293b"/>' +
'<ellipse cx="43" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/><ellipse cx="59" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/>' +
'<path d="M44,62 Q50,66 56,62" fill="none" stroke="#b45309" stroke-width="1.5" stroke-linecap="round"/>' +
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#cbd5e1" stroke-width="4" stroke-linecap="round"/>' +
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<rect x="12" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<rect x="78" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#94a3b8" stroke-width="2.8" stroke-linecap="round"/>' +
'<circle cx="82" cy="84" r="3.5" fill="#64748b" stroke="#94a3b8" stroke-width="1"/>' +
'<circle cx="88" cy="32" r="2.5" fill="' + c.accent + '" opacity="0.9"/>' +
'<circle cx="12" cy="40" r="1.5" fill="' + c.accent + '" opacity="0.5"/>' +
(c.hat === 'cap' ? capSvg(hid, c) : '') +
'</svg>'
);
}
function paint(el, key, color) {
if (!el) return;
el.innerHTML = svgAvatar((key || '').toLowerCase(), color);
}
function paintAll(root) {
root = root || document;
root.querySelectorAll('[data-agent-avatar]').forEach(function (el) {
paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color'));
});
}
return { paint: paint, paintAll: paintAll, svgAvatar: svgAvatar };
})();
+72 -35
View File
@@ -11,12 +11,9 @@
while (el.firstChild) el.removeChild(el.firstChild);
}
function curvePath(x1, y1, x2, y2) {
const cx1 = x1 + (x2 - x1) * 0.35;
const cy1 = y1;
const cx2 = x1 + (x2 - x1) * 0.72;
const cy2 = y2;
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
function linePath(x1, y1, x2, y2) {
const midY = y1 + (y2 - y1) * 0.45;
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
async function loadMeshData() {
@@ -28,63 +25,103 @@
function drawMesh(svg, data) {
clear(svg);
const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1000;
const height = vb && vb.height ? vb.height : 620;
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(width, height) * 0.35;
const width = vb && vb.width ? vb.width : 1100;
const height = vb && vb.height ? vb.height : 640;
const cx = width / 2;
const hermanY = 280;
const execY = 75;
const agentY = 520;
const souls = (data.nodes || []).filter((n) => {
const key = (n.agent_key || '').toLowerCase();
return key && key !== 'herman';
});
const souls = (data.nodes || []).filter((n) => (n.agent_key || '').toLowerCase() !== 'herman');
const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
const agentCount = Math.max(1, souls.length);
const pad = 70;
const span = width - pad * 2;
souls.forEach((node, i) => {
const a = (Math.PI * 2 * i) / Math.max(1, souls.length) - Math.PI / 2;
node._x = cx + Math.cos(a) * radius;
node._y = cy + Math.sin(a) * radius;
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
if (agentCount === 1) node._x = cx;
node._y = agentY;
});
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
const edgesLayer = make('g');
const nodesLayer = make('g');
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
souls.forEach((node) => {
const pathDef = curvePath(node._x, node._y, cx, cy);
const edge = make('path', { d: pathDef, class: 'mesh-edge' });
edgesLayer.appendChild(edge);
if (edgesBySource[node.agent_key]) {
const pulse = make('path', { d: pathDef, class: 'mesh-pulse' });
pulse.style.animationDuration = `${Math.max(1.2, 3.5 - Math.min(2.2, edgesBySource[node.agent_key].weight / 8))}s`;
edgesLayer.appendChild(pulse);
function addEdge(x1, y1, x2, y2, pulse) {
const d = linePath(x1, y1, x2, y2);
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
if (pulse) {
const p = make('path', { d, class: 'mesh-pulse' });
p.style.animationDuration = pulse + 's';
edgesLayer.appendChild(p);
}
}
souls.forEach((node) => {
const weight = edgesBySource[node.agent_key]?.weight || 1;
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
});
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
function drawExec(node, cls) {
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
g.lastChild.textContent = node.label;
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
sub.textContent = node.sub;
g.appendChild(sub);
nodesLayer.appendChild(g);
}
drawExec(ceo, 'mesh-ceo');
drawExec(cto, 'mesh-cto');
const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy, r: 62 }));
herman.appendChild(make('circle', { class: 'main', cx, cy, r: 46 }));
const crown = make('text', { x: cx, y: cy - 2, 'font-size': 24, 'text-anchor': 'middle' });
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
crown.textContent = '👑';
herman.appendChild(crown);
const label = make('text', { x: cx, y: cy + 22 });
label.textContent = 'Herman · Co-CEO';
herman.appendChild(label);
const hLabel = make('text', { x: cx, y: hermanY + 18 });
hLabel.textContent = 'Herman · Co-CEO';
herman.appendChild(hLabel);
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
hSub.textContent = 'Takenverdeler';
herman.appendChild(hSub);
nodesLayer.appendChild(herman);
souls.forEach((node) => {
const group = make('g', { class: `mesh-node ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 24 }));
const key = (node.agent_key || '').toLowerCase();
const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName;
group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 42, 'font-size': 10, opacity: 0.82 });
role.textContent = node.role_title || node.agent_key;
const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
group.appendChild(role);
const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
status.textContent = (node.health || 'idle').toUpperCase();
group.appendChild(status);
group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) {
Cockpit.toast(`${node.display_name || node.agent_key}: ${node.health || 'idle'}`, 'success');
Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
}
});
nodesLayer.appendChild(group);
+31 -13
View File
@@ -100,40 +100,58 @@ window.AnalyticsCharts = (function () {
}
}
function render(data) {
function render(data, mode) {
if (!data) return;
mode = mode || window.__vizMode || 'neo-bars';
window.__analyticsLastData = data;
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
barChart('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
var useRing = mode === 'neo-rings';
var bar = function (id, labels, values, label, horizontal) {
if (useRing) doughnut(id, labels, values);
else barChart(id, labels, values, label, horizontal);
};
bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
(data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR');
barChart('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
(data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten');
barChart('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
(data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
if (useRing) {
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
} else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
barChart('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
(data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true);
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
if (mode === 'neo-table') {
lineChart('chart-timeline', [], []);
} else {
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
}
doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }),
(data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); }));
barChart('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
(data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH');
doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }),
(data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); }));
barChart('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
(data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true);
barChart('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
(data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true);
barChart('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
(data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo');
doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }),
(data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
renderTables(data);
}
document.addEventListener('foodlinkk:vizmode', function (ev) {
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
});
return { render: render, destroyAll: destroyAll };
})();
+47 -17
View File
@@ -242,7 +242,7 @@ window.BriefingCharts = (function () {
{ label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' },
{ label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' },
{ label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') },
{ label: 'Halal kansen', sub: 'top score', icon: '🎯', color: '#a855f7', pct: '65%', val: stats.top_opportunities && stats.top_opportunities[0] ? Math.round(Number(stats.top_opportunities[0].halal_opportunity_score || 0)) + '/100' : '—' },
{ label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', pct: Math.min(95, ((stats.trending_food || stats.food_market_highlights || []).length * 10)) + '%', val: (stats.trending_food || stats.food_market_highlights || []).length || 0, link: '/marketing' },
{ label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
];
container.className = 'hm-neo-kpi-row';
@@ -257,11 +257,16 @@ window.BriefingCharts = (function () {
function renderRetail(container, stats) {
if (!container) return;
var opps = stats.top_opportunities || [];
if (!opps.length) { container.innerHTML = '<p class="empty-state">Geen kansen — <a href="/retail">open Retail 360</a></p>'; return; }
container.innerHTML = opps.map(function (o) {
var score = Math.round(Number(o.halal_opportunity_score) || 0);
return '<a href="/retail" class="retail-highlight"><span><strong>' + (o.chain || '') + ' · ' + (o.name || '') + '</strong><br><small>' + (o.city || '') + '</small></span><span class="ticker-item">' + score + '/100</span></a>';
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
return;
}
container.innerHTML = items.map(function (r) {
var when = (r.published_at || '').substring(0, 10);
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
}).join('');
}
@@ -289,16 +294,17 @@ window.BriefingCharts = (function () {
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var opps = (stats.top_opportunities || []).slice(0, 3);
var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5);
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (opps.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Top halal kansen</h4><ul class="hm-exec-list">';
opps.forEach(function (o) {
html += '<li><a href="/retail">' + (o.chain || '') + ' · ' + (o.name || '') + ' (' + (o.city || '') + ') — ' + Math.round(Number(o.halal_opportunity_score || 0)) + '/100</a></li>';
if (trending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
trending.forEach(function (r) {
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
});
html += '</ul>';
}
@@ -309,7 +315,19 @@ window.BriefingCharts = (function () {
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">RSS & reclame folders → <a href="/marketing">Marketing Hub</a> · Volledige analytics → <a href="/analytics">Analytics</a></p>';
var pending = stats.pending_approval_requests || [];
if (pending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
pending.forEach(function (p) {
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
container.innerHTML = html;
}
@@ -325,21 +343,33 @@ window.BriefingCharts = (function () {
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
}
function renderLive(root, stats) {
function renderLive(root, stats, vizMode) {
if (!root || !stats) return;
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
renderKpis(document.getElementById('briefing-kpis'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), stats);
var actEl = document.getElementById('briefing-activity-log');
if (actEl) {
var log = stats.activity_log || [];
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
}
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderWords(document.getElementById('chart-words'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
function render(root, stats, content, createdAt) {
renderLive(root, stats);
function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats, vizMode);
renderText(root, content, createdAt);
}
+3 -1
View File
@@ -40,7 +40,9 @@ window.Cockpit = (function () {
}
function confirmDelete(message) {
return window.confirm(message || 'Delete this item?');
var msg = message;
if (!msg && window.I18n) msg = window.I18n.t('common.confirm_delete');
return window.confirm(msg || 'Delete this item?');
}
function openDrawer(id) {
+97
View File
@@ -0,0 +1,97 @@
(function (global) {
var WIDGET_META = {
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
retail: { title: 'Retail operatie', icon: '🏪' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
feed: { title: 'Agent feed', icon: '⚡' },
};
function loadPrefs() {
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
return { dashboard_layout: Object.keys(WIDGET_META), global_viz_mode: 'neo-bars', viz_modes: {} };
});
}
function saveLayout(order) {
return fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dashboard_layout: order }),
});
}
function saveViz(globalMode, vizModes) {
return fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ global_viz_mode: globalMode, viz_modes: vizModes || {} }),
});
}
function applyOrder(container, order) {
if (!container) return;
var map = {};
Array.from(container.children).forEach(function (el) {
if (el.dataset && el.dataset.widget) map[el.dataset.widget] = el;
});
order.forEach(function (id) {
if (map[id]) container.appendChild(map[id]);
});
}
function initDrag(container, onReorder) {
if (!container) return;
var dragEl = null;
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
el.setAttribute('draggable', 'true');
el.classList.add('hm-widget-draggable');
el.addEventListener('dragstart', function (ev) {
dragEl = el;
el.classList.add('hm-widget-dragging');
ev.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', function () {
el.classList.remove('hm-widget-dragging');
dragEl = null;
var order = Array.from(container.querySelectorAll('[data-widget]')).map(function (n) { return n.dataset.widget; });
if (onReorder) onReorder(order);
});
el.addEventListener('dragover', function (ev) {
ev.preventDefault();
if (!dragEl || dragEl === el) return;
var rect = el.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
if (after) el.after(dragEl); else el.before(dragEl);
});
});
}
function buildVizSelector(container, prefs, onChange) {
if (!container || !global.VizEngine) return;
container.innerHTML = '<label class="viz-global-label">Visualisatie:</label>' +
global.VizEngine.MODES.map(function (m) {
var active = prefs.global_viz_mode === m.id ? ' active' : '';
return '<button type="button" class="viz-mode-btn' + active + '" data-viz="' + m.id + '">' + m.label + '</button>';
}).join('');
container.querySelectorAll('.viz-mode-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
container.querySelectorAll('.viz-mode-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
if (onChange) onChange(btn.dataset.viz);
});
});
}
global.DashboardLayout = {
WIDGET_META: WIDGET_META,
loadPrefs: loadPrefs,
saveLayout: saveLayout,
saveViz: saveViz,
applyOrder: applyOrder,
initDrag: initDrag,
buildVizSelector: buildVizSelector,
};
})(window);
+57
View File
@@ -0,0 +1,57 @@
(function (global) {
var mode = 'neo-bars';
function getMode() {
return mode;
}
function setMode(m, persist) {
mode = m;
global.__vizMode = m;
document.querySelectorAll('#global-viz-toolbar .global-viz-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.viz === m);
});
document.dispatchEvent(new CustomEvent('foodlinkk:vizmode', { detail: { mode: m } }));
if (persist !== false) {
fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ global_viz_mode: m }),
}).catch(function () {});
}
}
function buildToolbar(container) {
if (!container || !global.VizEngine) return;
container.innerHTML = global.VizEngine.MODES.map(function (m) {
var short = m.label.replace('Neo ', '');
var active = mode === m.id ? ' active' : '';
return '<button type="button" class="global-viz-btn' + active + '" data-viz="' + m.id + '" title="' + m.label + '">' + short + '</button>';
}).join('');
container.querySelectorAll('.global-viz-btn').forEach(function (btn) {
btn.addEventListener('click', function () { setMode(btn.dataset.viz); });
});
}
function init() {
if (global.location && global.location.pathname === '/') return;
fetch('/api/preferences/ui')
.then(function (r) { return r.json(); })
.then(function (prefs) {
mode = prefs.global_viz_mode || 'neo-bars';
global.__vizMode = mode;
buildToolbar(document.getElementById('global-viz-toolbar'));
document.dispatchEvent(new CustomEvent('foodlinkk:vizmode', { detail: { mode: mode } }));
})
.catch(function () {
buildToolbar(document.getElementById('global-viz-toolbar'));
});
}
global.GlobalViz = { getMode: getMode, setMode: setMode, init: init };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window);
+80
View File
@@ -0,0 +1,80 @@
/** Foodlinkk i18n — edit /static/i18n/nl.json and en.json to translate. */
window.I18n = (function () {
var locale = 'nl';
var strings = {};
function t(key, fallback) {
if (!key) return fallback || '';
var parts = String(key).split('.');
var o = strings;
for (var i = 0; i < parts.length; i++) {
if (o == null || typeof o !== 'object') return fallback != null ? fallback : key;
o = o[parts[i]];
}
return typeof o === 'string' ? o : (fallback != null ? fallback : key);
}
function applyDom() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n');
if (k) el.textContent = t(k);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
var k = el.getAttribute('data-i18n-placeholder');
if (k) el.placeholder = t(k);
});
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
var k = el.getAttribute('data-i18n-title');
if (k) el.title = t(k);
});
document.documentElement.lang = locale;
}
async function loadBundle(loc) {
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
strings = await r.json();
}
async function setLocale(loc, persist) {
if (persist == null) persist = true;
locale = loc === 'en' ? 'en' : 'nl';
try {
await loadBundle(locale);
} catch (e) {
if (locale !== 'nl') {
locale = 'nl';
await loadBundle('nl');
}
}
localStorage.setItem('foodlinkk_locale', locale);
applyDom();
window.dispatchEvent(new CustomEvent('foodlinkk:locale', { detail: { locale: locale } }));
if (persist) {
fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: locale }),
}).catch(function () {});
}
return locale;
}
async function init() {
var loc = localStorage.getItem('foodlinkk_locale') || 'nl';
try {
var prefs = await fetch('/api/preferences/ui').then(function (r) { return r.json(); });
if (prefs.locale) loc = prefs.locale;
} catch (e) { /* ignore */ }
await setLocale(loc, false);
}
return { t: t, init: init, setLocale: setLocale, locale: function () { return locale; }, applyDom: applyDom };
})();
document.addEventListener('alpine:init', function () {
if (!window.Alpine) return;
Alpine.magic('t', function () {
return function (key, fallback) { return window.I18n.t(key, fallback); };
});
});
+222
View File
@@ -0,0 +1,222 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs, text) {
const node = document.createElementNS(NS, tag);
Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, String(v)));
if (text != null) node.textContent = text;
return node;
}
function clear(root) {
while (root.firstChild) root.removeChild(root.firstChild);
}
function statusClass(status) {
const s = (status || 'unknown').toLowerCase();
if (['online', 'running', 'up'].includes(s)) return 'online';
if (['offline', 'down', 'stopped'].includes(s)) return 'offline';
return 'degraded';
}
function flattenTree(nodes, parentId) {
const out = [];
(nodes || []).forEach((n) => {
out.push({ ...n, parentId: parentId || null });
out.push(...flattenTree(n.children || [], n.id));
});
return out;
}
function layoutNodes(flat, width) {
const byType = { hardware: [], proxmox: [], vm: [], service: [] };
flat.forEach((n) => {
const t = n.type || 'service';
(byType[t] || byType.service).push(n);
});
const positions = {};
positions['hardware-r340'] = { x: width / 2, y: 70 };
positions['proxmox-host'] = { x: width / 2, y: 190 };
const vm = byType.vm[0];
if (vm) positions[vm.id] = { x: width / 2, y: 310 };
const services = byType.service;
const count = Math.max(services.length, 1);
const pad = 70;
const span = width - pad * 2;
services.forEach((s, i) => {
positions[s.id] = {
x: pad + (span * (i + 0.5)) / count,
y: 430,
};
});
flat.forEach((n, i) => {
if (!positions[n.id]) {
positions[n.id] = { x: 100 + (i % 6) * (width / 7), y: 430 };
}
});
positions['mon-sysops'] = { x: width * 0.32, y: 570 };
positions['mon-research'] = { x: width * 0.68, y: 570 };
return positions;
}
function drawLayerBands(svg, layers, width) {
const g = el('g', { class: 'ops-layers' });
(layers || []).forEach((layer) => {
g.appendChild(el('line', {
x1: 36, y1: layer.y - 28, x2: width - 36, y2: layer.y - 28,
class: 'ops-layer-line',
}));
g.appendChild(el('text', {
x: 14, y: layer.y - 33, class: 'ops-layer-label',
}, layer.label));
});
svg.appendChild(g);
}
function drawEdges(svg, flat, positions) {
const g = el('g', { class: 'ops-edges' });
flat.forEach((n) => {
if (!n.parentId) return;
const from = positions[n.parentId];
const to = positions[n.id];
if (!from || !to) return;
const yOffFrom = n.parentId === 'hardware-r340' ? 38 : 32;
const yOffTo = n.type === 'hardware' ? -38 : n.type === 'service' ? -22 : -30;
g.appendChild(el('line', {
x1: from.x, y1: from.y + yOffFrom, x2: to.x, y2: to.y + yOffTo,
class: 'topology-line',
}));
g.appendChild(el('line', {
x1: from.x, y1: from.y + yOffFrom, x2: to.x, y2: to.y + yOffTo,
class: 'pulse-line',
}));
});
svg.appendChild(g);
}
function drawHardwareNode(svg, n, pos) {
const cls = statusClass(n.status);
const g = el('g', {
class: `ops-node-group node-hardware status-${cls}`,
transform: `translate(${pos.x},${pos.y})`,
});
g.appendChild(el('rect', { x: -95, y: -36, width: 190, height: 72, rx: 8, class: 'ops-hardware-box' }));
g.appendChild(el('rect', { x: -88, y: -28, width: 12, height: 56, rx: 2, class: 'ops-hardware-slot' }));
g.appendChild(el('rect', { x: -70, y: -28, width: 12, height: 56, rx: 2, class: 'ops-hardware-slot' }));
g.appendChild(el('text', { x: -48, y: -6, class: 'ops-node-title ops-title-left' }, 'Dell PowerEdge'));
g.appendChild(el('text', { x: -48, y: 14, class: 'ops-node-sub ops-title-left' }, 'R340 · Bare metal'));
g.appendChild(el('text', { x: -48, y: 32, class: 'ops-node-role ops-title-left' }, cls.toUpperCase()));
svg.appendChild(g);
}
function drawNode(svg, n, pos) {
if (n.type === 'hardware') {
drawHardwareNode(svg, n, pos);
return;
}
const cls = statusClass(n.status);
const g = el('g', {
class: `ops-node-group node-${n.type || 'service'} status-${cls}`,
transform: `translate(${pos.x},${pos.y})`,
});
const r = n.type === 'proxmox' ? 38 : n.type === 'vm' ? 32 : 24;
g.appendChild(el('circle', { r: r, class: 'ops-node-circle' }));
const title = (n.label || n.id).split('·')[0].trim();
g.appendChild(el('text', { y: -6, class: 'ops-node-title' }, title.length > 18 ? title.slice(0, 16) + '…' : title));
if (n.type === 'vm' && n.vmid) {
g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, 'ID ' + n.vmid + ' · ' + cls.toUpperCase()));
} else {
g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, cls.toUpperCase()));
}
if (n.role) {
const role = n.role.length > 28 ? n.role.slice(0, 26) + '…' : n.role;
g.appendChild(el('text', { y: 24, class: 'ops-node-role' }, role));
}
if (n.url && n.type === 'service') {
const link = el('a', { href: n.url, target: '_blank', rel: 'noopener' });
link.appendChild(g);
svg.appendChild(link);
} else {
svg.appendChild(g);
}
}
function drawMonitorAgents(svg, agents, positions, stats) {
(agents || []).forEach((a) => {
const pos = positions[a.id] || { x: 200, y: 570 };
const stat = (stats || {})[a.agent_key] || {};
const health = stat.health || 'idle';
const g = el('g', {
class: `ops-node-group ops-monitor status-${health}`,
transform: `translate(${pos.x},${pos.y})`,
});
g.appendChild(el('rect', { x: -72, y: -30, width: 144, height: 60, rx: 10, class: 'ops-monitor-box' }));
g.appendChild(el('text', { y: -6, class: 'ops-node-title' }, a.label));
g.appendChild(el('text', { y: 10, class: 'ops-node-sub' }, health.toUpperCase()));
g.appendChild(el('text', { y: 24, class: 'ops-node-role' }, a.role || ''));
svg.appendChild(g);
});
}
async function loadAgentHealth() {
try {
const r = await fetch('/api/agents/mesh');
if (!r.ok) return {};
const data = await r.json();
const map = {};
(data.nodes || []).forEach((n) => { map[n.agent_key] = { health: n.health, events_6h: n.events_6h }; });
return map;
} catch (e) {
return {};
}
}
async function render(canvasId, metaEl) {
const svg = document.getElementById(canvasId);
if (!svg) return;
clear(svg);
const width = 1200;
const height = 620;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
try {
const [topoRes, agentStats] = await Promise.all([
fetch('/api/ops/topology').then((r) => r.json()),
loadAgentHealth(),
]);
const flat = flattenTree(topoRes.nodes || []);
const positions = layoutNodes(flat, width);
(topoRes.layer_agents || []).forEach((a) => {
if (!positions[a.id]) positions[a.id] = { x: width / 2, y: 570 };
});
drawLayerBands(svg, topoRes.layers, width);
drawEdges(svg, flat, positions);
flat.forEach((n) => {
const pos = positions[n.id];
if (pos) drawNode(svg, n, pos);
});
drawMonitorAgents(svg, topoRes.layer_agents, positions, agentStats);
if (metaEl) {
const online = (topoRes.meta && topoRes.meta.services_online) || 0;
metaEl.textContent =
'Update: ' + (topoRes.generated_at || '—').substring(0, 19).replace('T', ' ') +
' · ' + online + ' services online';
}
} catch (e) {
svg.appendChild(el('text', { x: 40, y: 60, fill: '#ef4444', 'font-size': 14 }, 'Topology laden mislukt: ' + e.message));
}
}
window.OpsTopology = { render };
})();
+27
View File
@@ -0,0 +1,27 @@
(function (global) {
function init(pageKey, renderFn) {
if (!global.DashboardLayout || typeof renderFn !== 'function') return Promise.resolve();
return global.DashboardLayout.loadPrefs().then(function (prefs) {
var mode = (prefs.viz_modes || {})[pageKey] || prefs.global_viz_mode || 'neo-bars';
var toolbar = document.getElementById('viz-toolbar');
if (toolbar) {
global.DashboardLayout.buildVizSelector(
toolbar,
Object.assign({}, prefs, { global_viz_mode: mode }),
function (m) {
var vizModes = Object.assign({}, prefs.viz_modes || {}, {});
vizModes[pageKey] = m;
global.DashboardLayout.saveViz(prefs.global_viz_mode, vizModes);
renderFn(m);
}
);
}
renderFn(mode);
document.addEventListener('foodlinkk:vizmode', function (ev) {
renderFn(ev.detail.mode);
});
});
}
global.PageViz = { init: init };
})(window);
+218
View File
@@ -0,0 +1,218 @@
(function (global) {
var MODES = [
{ id: 'neo-bars', label: 'Neo staafdiagram' },
{ id: 'neo-rings', label: 'Neo ringen' },
{ id: 'neo-equalizer', label: 'Neo equalizer' },
{ id: 'neo-cards', label: 'Neo kaarten' },
{ id: 'neo-table', label: 'Neo tabel' },
];
var charts = {};
function destroy(key) {
if (charts[key]) {
charts[key].destroy();
charts[key] = null;
}
}
function colors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
purple: 'rgba(168, 85, 247, 0.9)', gray: 'rgba(159, 176, 196, 0.85)',
grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function eqBars(counts, cls) {
var max = Math.max.apply(null, counts.concat([1]));
return '<div class="hm-eq viz-eq ' + (cls || '') + '">' +
counts.map(function (v) {
var h = Math.max(18, Math.round((v / max) * 92));
return '<span style="height:' + h + '%"></span>';
}).join('') + '</div>';
}
function findPanel(canvas, alt) {
var el = (canvas && canvas.closest('.page-viz-panel')) || (alt && alt.closest('.page-viz-panel'));
return el || null;
}
function applyPanelLayout(canvas, alt, mode) {
var panel = findPanel(canvas, alt);
if (!panel) return;
var isCanvas = mode === 'neo-bars' || mode === 'neo-rings';
panel.classList.toggle('viz-mode-chart', isCanvas);
panel.classList.toggle('viz-mode-alt', !isCanvas);
panel.classList.toggle('viz-mode-rings', mode === 'neo-rings');
}
function useHorizontal(labels, opts) {
if (opts && opts.horizontal !== undefined) return opts.horizontal;
return (labels || []).length >= 6;
}
function renderBars(canvas, labels, values, opts) {
if (!canvas || typeof Chart === 'undefined') return;
opts = opts || { key: 'chart' };
var c = colors();
var horiz = useHorizontal(labels, opts);
destroy(opts.key);
charts[opts.key] = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{ data: values, backgroundColor: opts.color || c.cyan, borderRadius: 6, barThickness: horiz ? 'flex' : undefined, maxBarThickness: horiz ? 28 : 48 }],
},
options: {
indexAxis: horiz ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 4, right: 8, bottom: 4, left: 4 } },
plugins: { legend: { display: false }, title: { display: !!opts.title, text: opts.title, color: c.text, padding: { bottom: 8 } } },
scales: {
y: horiz
? { ticks: { color: c.text, autoSkip: false, font: { size: 11 } }, grid: { color: c.grid } }
: { ticks: { color: c.text }, grid: { color: c.grid }, beginAtZero: true },
x: horiz
? { ticks: { color: c.text }, grid: { display: false }, beginAtZero: true }
: { ticks: { color: c.text, maxRotation: 45, minRotation: 0, autoSkip: false, font: { size: 10 } }, grid: { display: false } },
},
},
});
requestAnimationFrame(function () {
if (charts[opts.key]) charts[opts.key].resize();
});
}
function renderRings(canvas, labels, values, key, title) {
if (!canvas || typeof Chart === 'undefined') return;
var c = colors();
destroy(key);
charts[key] = new Chart(canvas, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{ data: values, backgroundColor: [c.green, c.gray, c.red, c.gold, c.cyan, c.purple], borderWidth: 0 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
layout: { padding: 4 },
plugins: {
legend: { position: 'bottom', labels: { color: c.text, boxWidth: 10, padding: 8, font: { size: 10 } } },
title: { display: !!title, text: title, color: c.text, font: { size: 11 } },
},
},
});
requestAnimationFrame(function () {
if (charts[key]) charts[key].resize();
});
}
function renderCards(container, rows) {
if (!container) return;
container.innerHTML = '<div class="viz-cards">' + rows.map(function (r) {
return '<div class="viz-card"><span class="viz-card-label">' + r.label + '</span><strong class="viz-card-val">' + r.value + '</strong></div>';
}).join('') + '</div>';
}
function renderTable(container, rows) {
if (!container) return;
container.innerHTML = '<table class="data-table viz-table"><thead><tr><th>Item</th><th>Waarde</th></tr></thead><tbody>' +
rows.map(function (r) { return '<tr><td>' + r.label + '</td><td>' + r.value + '</td></tr>'; }).join('') +
'</tbody></table>';
}
function renderEqualizer(container, rows, cls) {
if (!container) return;
container.innerHTML = rows.map(function (r) {
return '<div class="viz-eq-row"><span>' + r.label + '</span><strong>' + r.value + '</strong></div>';
}).join('') + eqBars(rows.map(function (r) { return Number(r.raw) || 0; }), cls);
}
function renderWidget(mode, spec) {
var canvas = spec.canvas;
var alt = spec.altContainer;
applyPanelLayout(canvas, alt, mode);
if (mode === 'neo-bars' && canvas) {
if (alt) alt.innerHTML = '';
canvas.style.display = '';
renderBars(canvas, spec.labels, spec.values, spec.chartOpts || { key: spec.key });
return;
}
if (mode === 'neo-rings' && canvas) {
if (alt) alt.innerHTML = '';
canvas.style.display = '';
renderRings(canvas, spec.labels, spec.values, spec.key, spec.title);
return;
}
if (canvas) canvas.style.display = 'none';
if (!alt) return;
if (mode === 'neo-equalizer') renderEqualizer(alt, spec.rows, spec.eqClass);
else if (mode === 'neo-cards') renderCards(alt, spec.rows);
else if (mode === 'neo-table') renderTable(alt, spec.rows);
else {
if (canvas) { canvas.style.display = ''; renderBars(canvas, spec.labels, spec.values, spec.chartOpts || { key: spec.key }); }
}
}
global.VizEngine = {
MODES: MODES,
renderWidget: renderWidget,
destroyAll: function () { Object.keys(charts).forEach(destroy); },
renderSentiment: function (mode, canvas, alt, stats, widgetMode) {
var m = widgetMode || mode;
var files = stats.nas_files || [];
var counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) {
var s = (f.sentiment_label || 'neutral').toLowerCase();
if (counts[s] !== undefined) counts[s]++;
});
if (!files.length) counts.neutral = 1;
renderWidget(m, {
canvas: canvas, altContainer: alt, key: 'sentiment',
labels: ['Positief', 'Neutraal', 'Negatief'],
values: [counts.positive, counts.neutral, counts.negative],
title: 'NAS sentiment',
rows: [
{ label: 'Positief', value: counts.positive, raw: counts.positive },
{ label: 'Neutraal', value: counts.neutral, raw: counts.neutral },
{ label: 'Negatief', value: counts.negative, raw: counts.negative },
],
eqClass: 'hm-eq-purple',
});
},
renderAgents: function (mode, canvas, alt, stats, widgetMode) {
var m = widgetMode || mode;
var map = {};
(stats.recent_events || []).forEach(function (e) {
var a = e.agent_name || 'other';
map[a] = (map[a] || 0) + 1;
});
var labels = Object.keys(map);
var values = labels.map(function (k) { return map[k]; });
if (!labels.length) { labels = ['geen']; values = [0]; }
renderWidget(m, {
canvas: canvas, altContainer: alt, key: 'agents',
labels: labels, values: values, title: 'Agent activiteit',
chartOpts: { key: 'agents', color: colors().gold },
rows: labels.map(function (l, i) { return { label: l, value: values[i], raw: values[i] }; }),
eqClass: 'hm-eq-lime',
});
},
renderFromRows: function (mode, canvas, alt, key, title, rows, eqClass, chartColor) {
var labels = rows.map(function (r) { return r.label; });
var values = rows.map(function (r) { return Number(r.raw) || 0; });
renderWidget(mode, {
canvas: canvas, altContainer: alt, key: key,
labels: labels, values: values, title: title,
chartOpts: { key: key, color: chartColor || colors().cyan },
rows: rows,
eqClass: eqClass || 'hm-eq-cyan',
});
},
};
})(window);
+221 -49
View File
@@ -1,8 +1,11 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
<script src="/static/js/agent-avatars.js?v=3"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
@@ -11,6 +14,15 @@
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
<div id="viz-agents-alt" class="viz-alt"></div>
</div>
</div>
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
@@ -19,6 +31,10 @@
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
</button>
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
<span class="vtab-icon"></span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
</nav>
<div class="vtabs-content">
@@ -27,54 +43,41 @@
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">orchestrator</div></div></div><div class="hm-neo-kpi-value">CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">Co-CEO · takenverdeler</div></div></div><div class="hm-neo-kpi-value" style="font-size:1.1rem">Co-CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</div>
<div class="agents-neo-grid">
<template x-for="s in souls" :key="s.agent_key">
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
<div class="hm-neo-kpi-top">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(s.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
<span class="agent-status-dot" x-show="s.is_active !== false"></span>
</div>
<div style="flex:1">
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
</div>
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
<div class="agent-char-wrap" :class="s.agent_key"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-status-pill" :class="statusClass(s)">
<span class="dot"></span>
<span x-text="statusLabel(s)"></span>
</span>
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
<p class="agent-role-desc" x-text="(s.responsibilities || '').substring(0,120)"></p>
<div class="agent-meta-row">
<small x-text="(s.event_count||0) + ' events'"></small>
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small>
<button type="button" class="agent-events-link"
@click.stop="openEvents(s.agent_key, s.display_name)"
x-text="(s.event_count||0) + ' events'"
:title="'Bekijk wat ' + s.display_name + ' heeft gedaan'"></button>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
<div class="hm-eq" style="margin-top:0.5rem"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</article>
</template>
</div>
<div class="agent-soul-editor" x-show="selected" x-cloak>
<div class="agent-editor-header">
<div class="agent-avatar-wrap">
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')">
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]">
<span class="hair" x-show="hasHair(selected?.agent_key)"></span>
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
</div>
<div class="agent-char-wrap large" :class="selected?.agent_key"
:data-agent-avatar="selected?.agent_key"
:data-agent-color="selected?.avatar_color || '#1e3a5f'"
x-effect="selected && $nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, selected.agent_key, selected.avatar_color))"></div>
<div style="flex:1">
<h2><span x-text="selected?.display_name"></span><span x-text="selected?.role_title"></span></h2>
<p style="margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text="selected?.responsibilities"></p>
@@ -87,9 +90,16 @@
<label>Rol titel<input class="form-input" x-model="form.role_title"></label>
<label>Functie & werkzaamheden<textarea class="form-input" rows="3" x-model="form.responsibilities"></textarea></label>
<label>Soul.md (karakter, tone of voice, regels)<textarea x-model="form.soul_md"></textarea></label>
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-top:1rem">Recente activiteit</h3>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin:0">Recente activiteit</h3>
<button type="button" class="agent-events-link" x-show="selected" @click="openEvents(selected.agent_key, selected.display_name)">Alle events →</button>
</div>
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
<div class="hub-feed-item"><span x-text="ev.created_at?.substring(0,16)"></span> <span x-text="ev.title||ev.event_type"></span></div>
<button type="button" class="hub-feed-item agent-event-clickable" @click="openEvents(selected.agent_key, selected.display_name, ev.id)">
<span class="agent-event-time" x-text="formatEventTime(ev.created_at)"></span>
<span class="agent-event-title" x-text="ev.title||ev.event_type"></span>
<span class="badge badge-sm" x-text="ev.status||'—'"></span>
</button>
</template>
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
</div>
@@ -98,7 +108,7 @@
<section x-show="tab==='mesh'" x-cloak>
<div class="panel">
<h2 style="margin-top:0;">Netwerk</h2>
<p class="page-tip" style="margin-top:.25rem;">Communication mesh met Herman als centrale Co-CEO hub.</p>
<p class="page-tip" style="margin-top:.25rem;">Agents onderaan → Herman (Co-CEO) → CEO &amp; CTO. Pulse = actieve communicatie.</p>
<div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
</div>
@@ -110,11 +120,82 @@
</div>
</div>
</section>
<section x-show="tab==='approvals'" x-cloak x-data="approvalsPanel()" x-init="load()">
<div class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem">
<div>
<h2 style="margin:0;">Approval queue</h2>
<p class="page-tip">Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.</p>
</div>
<button class="btn btn-sm btn-pulse" @click="load()">↻ Verversen</button>
</div>
<table class="data-table">
<thead><tr><th>#</th><th>Agent</th><th>Actie</th><th>Titel</th><th>Status</th><th>Tijd</th><th></th></tr></thead>
<tbody>
<template x-for="r in items" :key="r.id">
<tr>
<td x-text="r.id"></td>
<td x-text="r.agent_key"></td>
<td x-text="r.action_type"></td>
<td x-text="r.title"></td>
<td><span class="badge" :class="r.status==='pending'?'badge-warn':'badge-positive'" x-text="r.status"></span></td>
<td x-text="(r.created_at||'').substring(0,16).replace('T',' ')"></td>
<td>
<template x-if="r.status==='pending'">
<span>
<button class="btn btn-sm btn-approve" @click="approve(r.id)"></button>
<button class="btn btn-sm btn-reject" @click="reject(r.id)"></button>
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
<p x-show="!items.length" class="muted">Geen approval requests.</p>
</div>
</section>
</div>
</div>
<div class="agent-events-backdrop" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<aside class="agent-events-drawer" :class="{ open: eventsOpen }" x-cloak>
<header class="agent-events-head">
<div>
<h3 x-text="eventsAgentName || 'Agent'"></h3>
<p class="muted" x-text="eventsList.length + ' activiteiten gelogd'"></p>
</div>
<button type="button" class="btn btn-sm" @click="closeEvents()"></button>
</header>
<div class="agent-events-body" x-show="!eventsLoading">
<template x-for="ev in eventsList" :key="ev.id">
<article class="agent-event-card" :class="{ expanded: selectedEvent?.id === ev.id }" @click="toggleEventDetail(ev)">
<div class="agent-event-card-top">
<time x-text="formatEventTime(ev.created_at)"></time>
<span class="badge badge-sm" :class="eventStatusClass(ev.status)" x-text="ev.status || 'completed'"></span>
</div>
<strong x-text="ev.title || ev.event_type"></strong>
<div class="agent-event-meta">
<span x-text="ev.event_type"></span>
<span x-show="ev.channel" x-text="'· ' + ev.channel"></span>
</div>
<div class="agent-event-detail" x-show="selectedEvent?.id === ev.id" x-cloak>
<p x-show="ev.body" x-text="ev.body"></p>
<pre x-show="!ev.body && ev.metadata && Object.keys(ev.metadata||{}).length" x-text="JSON.stringify(ev.metadata, null, 2)"></pre>
<p x-show="!ev.body && !(ev.metadata && Object.keys(ev.metadata||{}).length)" class="muted">Geen extra details voor dit event.</p>
</div>
</article>
</template>
<p x-show="!eventsList.length && !eventsLoading" class="muted" style="padding:1rem">Nog geen events voor deze agent.</p>
</div>
<p x-show="eventsLoading" class="muted" style="padding:1.5rem">Events laden…</p>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js"></script>
<script>
function agentsSoulHub() {
@@ -123,22 +204,67 @@ function agentsSoulHub() {
tabIndicatorTop: 0,
tabIndicatorH: 40,
meshLoaded: false,
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false,
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
avatarStyle(key) {
const m = {
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob',
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl',
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short',
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses',
};
return m[key] || 'style-male-short';
statusClass(s) {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval' || st === 'pending') return 'busy';
if (st === 'error' || st === 'rejected') return 'error';
if (st === 'completed' || (s.events_6h > 0)) return 'active';
return 'idle';
},
formatEventTime(ts) {
return (ts || '').substring(0, 16).replace('T', ' ');
},
eventStatusClass(status) {
const s = (status || '').toLowerCase();
if (s === 'pending' || s === 'needs_approval') return 'badge-warn';
if (s === 'error' || s === 'rejected') return 'badge-negative';
if (s === 'approved' || s === 'completed') return 'badge-positive';
return '';
},
async openEvents(key, name, focusId) {
if (!key) return;
this.eventsAgentKey = key;
this.eventsAgentName = name || key;
this.eventsOpen = true;
this.eventsLoading = true;
this.selectedEvent = null;
try {
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=50').then(x => x.json());
this.eventsList = r.items || [];
if (focusId) {
const hit = this.eventsList.find(e => e.id === focusId);
if (hit) this.selectedEvent = hit;
}
} catch (e) { this.eventsList = []; }
this.eventsLoading = false;
},
closeEvents() {
this.eventsOpen = false;
this.selectedEvent = null;
},
toggleEventDetail(ev) {
this.selectedEvent = this.selectedEvent?.id === ev.id ? null : ev;
},
statusLabel(s) {
const st = (s.current_status || '').toLowerCase();
if (st === 'needs_approval') return 'Wacht op goedkeuring';
if (st === 'pending') return 'In wachtrij';
if (st === 'completed') return 'Klaar';
if (st === 'error') return 'Fout';
if (s.current_task) return 'Bezig';
return 'Standby';
},
hasHair(key) { return !!key; },
hasGlasses(key) { return ['herman','research','finance','legal'].includes(key); },
async init() {
await this.load();
await this.loadPendingCount();
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn);
@@ -159,6 +285,13 @@ function agentsSoulHub() {
url.searchParams.set('tab', next);
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
if (next === 'approvals') this.loadPendingCount();
},
async loadPendingCount() {
try {
const r = await fetch('/api/agents/approvals?status=pending').then(x => x.json());
this.pendingCount = (r.items || []).length;
} catch (e) { this.pendingCount = 0; }
},
mountMesh() {
if (this.meshLoaded || !window.AgentsMesh) return;
@@ -169,6 +302,22 @@ function agentsSoulHub() {
const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || [];
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
this.renderViz(window.__vizMode || 'neo-bars');
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
},
renderViz(mode) {
if (!window.VizEngine) return;
const rows = (this.souls || []).map(s => ({
label: s.agent_key,
value: s.event_count || 0,
raw: s.event_count || 0,
})).sort((a, b) => b.raw - a.raw).slice(0, 12);
if (!rows.length) rows.push({ label: 'geen', value: 0, raw: 0 });
VizEngine.renderFromRows(mode,
document.getElementById('chart-agents-viz'),
document.getElementById('viz-agents-alt'),
'agents-page', 'Events per agent', rows, 'hm-eq-lime'
);
},
async select(key) {
const r = await fetch('/api/agents/souls/' + key).then(x => x.json());
@@ -188,5 +337,28 @@ function agentsSoulHub() {
}
};
}
function approvalsPanel() {
return {
items: [],
async load() {
try {
const r = await fetch('/api/agents/approvals?limit=50').then(x => x.json());
this.items = r.items || [];
} catch (e) { this.items = []; }
},
async approve(id) {
await Cockpit.api('/api/agents/approvals/' + id + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd — agent mag query uitvoeren', 'success');
await this.load();
},
async reject(id) {
const reason = prompt('Reden afwijzing (optioneel):') || '';
await Cockpit.api('/api/agents/approvals/' + id + '/reject', { method: 'POST', body: JSON.stringify({ reason }) });
Cockpit.toast('Afgewezen', 'info');
await this.load();
},
};
}
</script>
{% endblock %}
+1 -1
View File
@@ -71,5 +71,5 @@
{% endblock %}
{% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=1"></script>
<script src="/static/js/analytics.js?v=2"></script>
{% endblock %}
+45 -27
View File
@@ -17,6 +17,7 @@
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=3" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block extra_head %}{% endblock %}
@@ -30,46 +31,48 @@
</div>
<div class="app-sidebar-group">
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}">Herman</a>
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">Agents</div>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
<div class="app-sidebar-label" data-i18n="nav.agents">Agents</div>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label">System</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}">IT Ops</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
<div class="app-sidebar-label" data-i18n="nav.system">Systeem</div>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
</div>
</aside>
@@ -79,6 +82,11 @@
<header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen"></button>
<span class="page-tip">Foodlinkk</span>
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
<option value="nl">NL</option>
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate"></strong> · <span x-text="clockTime"></span></div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
@@ -91,11 +99,15 @@
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div>
<script src="/static/js/cockpit.js"></script>
<script src="/static/js/i18n.js?v=2"></script>
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=2"></script>
<script>
function appShell() {
return {
locale: 'nl',
drawerOpen: false,
navOpen: false,
showInstall: false,
@@ -103,10 +115,16 @@
clockDate: '—',
clockTime: '—',
_offset: 0,
initApp() {
async initApp() {
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock();
this.initPwa();
},
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
},
async initClock() {
try {
const r = await fetch('/api/server-time').then(x => x.json());
+527 -42
View File
@@ -1,54 +1,539 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="clientsCrud()">
<header class="page-header"><h1>Clients</h1>
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
<button class="btn btn-primary" @click="openModal()">Add client</button>
<div class="kanban">
{% set stages = ['intake','discovery','proposal','active','churned'] %}
{% for st in stages %}
<div class="kanban-col"><h3>{{ st }}</h3>
{% for c in clients if c.stage == st %}
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('clients.title')">CRM · Klanten</h1>
<p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/deals">Deals →</a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span>Totaal klanten</span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span>Actief</span><strong x-text="stats.active||0"></strong></div>
<div class="clients-kpi"><span>MRR schatting</span><strong x-text="fmtEur(stats.mrr_total)"></strong></div>
<div class="clients-kpi"><span>Pipeline</span><strong x-text="fmtEur(stats.pipeline_value)"></strong></div>
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
</div>
</div>
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'" x-text="$t('clients.tab_kanban')">Kanban</button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('clients.tab_list')">Lijst</button>
<button class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; searchStores()" x-text="$t('clients.tab_stores')">Supermarkten</button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openClientModal()" x-text="'+ ' + $t('clients.new')">+ Nieuwe klant</button>
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
</div>
</div>
<!-- Kanban -->
<div x-show="tab==='kanban'" x-cloak>
<div class="clients-kanban">
<template x-for="st in stages" :key="st.id">
<div class="clients-kanban-col">
<h3><span x-text="st.label"></span> <span class="count" x-text="stageCount(st.id)"></span></h3>
<template x-for="c in clientsByStage(st.id)" :key="c.id">
<div class="clients-card" :class="'stage-'+c.stage" @click="openDetail(c)">
<strong x-text="c.name"></strong>
<div class="clients-card-meta">
<span x-show="c.contact" x-text="c.contact"></span>
<span x-show="c.sector"> · <span x-text="c.sector"></span></span>
</div>
<div class="clients-card-meta" x-show="c.email" x-text="c.email"></div>
<div class="clients-card-tags">
<span class="clients-tag mrr" x-show="c.mrr_estimate" x-text="'€'+Number(c.mrr_estimate).toLocaleString('nl-NL')+'/mnd'"></span>
<span class="clients-tag" x-show="c.store_count" x-text="c.store_count+' filialen'"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- List -->
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek naam, contact, e-mail…" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterStage" @change="applyFilter()">
<option value="">Alle stages</option>
<template x-for="st in stages" :key="'f'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
<select x-model="filterSector" @change="applyFilter()">
<option value="">Alle sectoren</option>
<template x-for="s in sectorOptions" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th>Naam</th><th>Contact</th><th>E-mail</th><th>Sector</th><th>Stage</th><th>MRR</th><th>Filialen</th><th></th>
</tr></thead>
<tbody>
<template x-for="c in filtered" :key="'l'+c.id">
<tr class="clickable-row" @click="openDetail(c)">
<td><strong x-text="c.name"></strong></td>
<td x-text="c.contact||'—'"></td>
<td x-text="c.email||'—'"></td>
<td x-text="c.sector||'—'"></td>
<td><span class="badge" x-text="stageLabel(c.stage)"></span></td>
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
<td x-text="c.store_count||0"></td>
<td>
<button class="btn btn-sm" @click.stop="editClient(c)">Bewerk</button>
<button class="btn btn-sm btn-reject" @click.stop="removeClient(c.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
<p class="clients-empty" x-show="!filtered.length">Geen klanten gevonden.</p>
</div>
<!-- Supermarkten -->
<div x-show="tab==='stores'" x-cloak>
<p class="page-tip" style="margin-bottom:0.75rem">Zoek een filiaal, vul ontbrekende data aan (contact, halal, manager) en koppel aan een klant.</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek filiaal, keten, stad…" x-model="storeQ" @keyup.enter="searchStores()" />
<select x-model="storeChain" @change="searchStores()">
<option value="">Alle ketens</option>
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
</select>
<select x-model="storeProvince" @change="searchStores()">
<option value="">Alle provincies</option>
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
</select>
<button class="btn btn-sm btn-pulse" @click="searchStores()" :disabled="storeBusy">Zoeken</button>
</div>
<div class="store-search-panel">
<div>
<div class="store-list">
<template x-for="s in storeResults" :key="s.id">
<div class="store-list-item" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="(s.city||'') + (s.province ? ', '+s.province : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag" x-show="s.partnership_status && s.partnership_status!=='none'" x-text="s.partnership_status"></span>
<span class="clients-tag" x-show="s.halal_certified">Halal ✓</span>
</div>
</div>
</template>
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
</div>
<p class="muted" style="font-size:0.72rem;margin-top:0.35rem" x-text="storeResults.length+' filialen · max 80'"></p>
</div>
<div class="store-edit-panel" x-show="selectedStore">
<h3 x-text="(selectedStore?.chain||'') + ' · ' + (selectedStore?.name||'')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Adres</span>
<input class="form-input" x-model="storeForm.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="storeForm.postcode" /></label>
<label><span class="filter-label">Stad</span>
<input class="form-input" x-model="storeForm.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="storeForm.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="storeForm.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="storeForm.email" /></label>
<label><span class="filter-label">Website</span>
<input class="form-input" x-model="storeForm.website" /></label>
<label><span class="filter-label">Manager</span>
<input class="form-input" x-model="storeForm.manager_name" /></label>
<label><span class="filter-label">Partnership</span>
<select class="form-input" x-model="storeForm.partnership_status">
<option value="none">Geen</option><option value="prospect">Prospect</option>
<option value="active">Actief</option><option value="paused">Gepauzeerd</option>
</select></label>
<label><span class="filter-label">Halal certificerder</span>
<input class="form-input" x-model="storeForm.halal_certifier" placeholder="HQC, OSM…" /></label>
</div>
<div class="store-checks">
<label><input type="checkbox" x-model="storeForm.halal_certified" /> Halal gecertificeerd</label>
<label><input type="checkbox" x-model="storeForm.has_halal_section" /> Halal schap</label>
</div>
<div class="clients-form-grid" style="margin-top:0.75rem">
<label class="full"><span class="filter-label">Koppel aan klant</span>
<select class="form-input" x-model="linkForm.client_id">
<option value="">— Selecteer klant —</option>
<template x-for="c in clients" :key="'lc'+c.id">
<option :value="c.id" x-text="c.name + ' ('+stageLabel(c.stage)+')'"></option>
</template>
</select></label>
<label><span class="filter-label">Relatie</span>
<select class="form-input" x-model="linkForm.relationship_type">
<option value="prospect">Prospect</option><option value="partner">Partner</option>
<option value="supplier">Leverancier</option><option value="competitor">Concurrent</option>
</select></label>
<label><span class="filter-label">Notities koppeling</span>
<input class="form-input" x-model="linkForm.notes" placeholder="Optioneel" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary btn-sm" @click="saveStore()" :disabled="storeBusy">Opslaan filiaal</button>
<button class="btn btn-sm btn-pulse-green" @click="linkStore()" :disabled="!linkForm.client_id">Koppel klant</button>
<a class="btn btn-sm" :href="'/retail?store='+selectedStore?.id" target="_blank">Retail 360 →</a>
</div>
</div>
<div class="store-edit-panel clients-empty" x-show="!selectedStore && !storeBusy">
Selecteer een filiaal links om data aan te vullen.
</div>
</div>
</div>
<!-- Client modal -->
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="clientForm.id ? 'Klant bewerken' : 'Nieuwe klant'"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Bedrijfsnaam *</span>
<input class="form-input" x-model="clientForm.name" placeholder="Chai N Masala B.V." /></label>
<label><span class="filter-label">Contactpersoon</span>
<input class="form-input" x-model="clientForm.contact" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" type="email" x-model="clientForm.email" /></label>
<label><span class="filter-label">Sector</span>
<select class="form-input" x-model="clientForm.sector">
<option value="">— Kies sector —</option>
<template x-for="s in sectorOptions" :key="'s'+s"><option :value="s" x-text="s"></option></template>
</select></label>
<label><span class="filter-label">Stage</span>
<select class="form-input" x-model="clientForm.stage">
<template x-for="st in stages" :key="'cs'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select></label>
<label><span class="filter-label">MRR schatting (€/mnd)</span>
<input class="form-input" type="number" min="0" step="100" x-model="clientForm.mrr_estimate" /></label>
<label class="full"><span class="filter-label">Notities</span>
<textarea class="form-input" rows="4" x-model="clientForm.notes" placeholder="Context, afspraken, next steps…"></textarea></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="saveClient()" :disabled="!clientForm.name.trim()">Opslaan</button>
<button class="btn btn-secondary" @click="clientModal=false">Annuleren</button>
<button class="btn btn-reject" x-show="clientForm.id" @click="removeClient(clientForm.id); clientModal=false">Verwijderen</button>
</div>
</div>
</div>
<!-- Store create modal -->
<div class="modal" :class="{ open: storeCreateModal }" @click.self="storeCreateModal=false">
<div class="modal-card" style="max-width:560px">
<h3>Nieuw filiaal toevoegen</h3>
<div class="clients-form-grid">
<label><span class="filter-label">Keten *</span>
<input class="form-input" x-model="newStore.chain" list="chain-list" placeholder="Jumbo, AH, Lidl…" /></label>
<datalist id="chain-list"><template x-for="ch in chains" :key="'dl'+ch"><option :value="ch"></option></template></datalist>
<label><span class="filter-label">Naam *</span>
<input class="form-input" x-model="newStore.name" placeholder="Jumbo Bos en Lommer" /></label>
<label class="full"><span class="filter-label">Adres *</span>
<input class="form-input" x-model="newStore.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="newStore.postcode" /></label>
<label><span class="filter-label">Stad *</span>
<input class="form-input" x-model="newStore.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="newStore.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="newStore.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="newStore.email" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="createStore()" :disabled="!newStore.name||!newStore.chain||!newStore.city">Toevoegen</button>
<button class="btn btn-secondary" @click="storeCreateModal=false">Annuleren</button>
</div>
</div>
</div>
<!-- Detail drawer -->
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div>
<h2 x-text="detail?.client?.name||''"></h2>
<p class="muted" x-text="stageLabel(detail?.client?.stage)+' · '+(detail?.client?.sector||'Geen sector')"></p>
</div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.client">
<div>
<div class="clients-card-meta">
<div x-show="detail.client.contact"><strong>Contact:</strong> <span x-text="detail.client.contact"></span></div>
<div x-show="detail.client.email"><strong>E-mail:</strong> <span x-text="detail.client.email"></span></div>
<div x-show="detail.client.mrr_estimate"><strong>MRR:</strong> <span x-text="fmtEur(detail.client.mrr_estimate)"></span></div>
<div x-show="detail.client.notes" style="margin-top:0.5rem;white-space:pre-wrap" x-text="detail.client.notes"></div>
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
<select class="form-input" style="width:auto" x-model="detail.client.stage" @change="quickStage(detail.client)">
<template x-for="st in stages" :key="'ds'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
</div>
<div class="clients-drawer-section">
<h4>Deals (<span x-text="(detail.deals||[]).length"></span>)</h4>
<template x-for="d in detail.deals||[]" :key="d.id">
<div class="clients-card" style="cursor:default">
<strong x-text="d.title"></strong>
<div class="clients-card-meta" x-text="fmtEur(d.value)+' · '+d.stage"></div>
</div>
</template>
<p class="muted" x-show="!(detail.deals||[]).length">Nog geen deals — <a href="/deals">voeg toe</a></p>
</div>
<div class="clients-drawer-section">
<h4>Gekoppelde filialen (<span x-text="(detail.stores||[]).length"></span>)</h4>
<template x-for="s in detail.stores||[]" :key="s.id">
<div class="clients-card" style="cursor:default">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="s.city+(s.province?', '+s.province:'')"></div>
<span class="clients-tag" x-text="s.relationship_type||s.partnership_status"></span>
</div>
</template>
<button class="btn btn-sm" style="margin-top:0.5rem" @click="tab='stores'; drawer=false">+ Filiaal koppelen</button>
</div>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsCrud() {
function clientsHub() {
const STAGES = [
{ id: 'intake', label: 'Intake' },
{ id: 'discovery', label: 'Verkenning' },
{ id: 'proposal', label: 'Voorstel' },
{ id: 'active', label: 'Actief' },
{ id: 'churned', label: 'Verloren' },
];
const SECTORS = [
'Retail', 'Supermarkt', 'Groothandel', 'Horeca', 'Food service',
'Spices & Tea', 'Kant-en-klaar', 'Import/Export', 'Private label', 'Overig',
];
return {
modal: false, drawer: false, detail: '',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
async save() {
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
location.reload();
tab: 'kanban',
busy: false,
storeBusy: false,
clientModal: false,
storeCreateModal: false,
drawer: false,
stages: STAGES,
sectorOptions: SECTORS,
stats: {},
clients: [],
filtered: [],
filterQ: '',
filterStage: '',
filterSector: '',
detail: null,
clientForm: {},
storeResults: [],
selectedStore: null,
storeForm: {},
linkForm: { client_id: '', relationship_type: 'prospect', notes: '' },
storeQ: '',
storeChain: '',
storeProvince: '',
chains: [],
provinces: [],
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
fmtEur(v) {
if (v == null || v === '') return '—';
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
},
stageLabel(id) {
return (STAGES.find(s => s.id === id) || {}).label || id || '—';
},
stageCount(id) { return this.clients.filter(c => c.stage === id).length; },
clientsByStage(id) { return this.clients.filter(c => c.stage === id); },
async init() { await this.loadAll(); },
async loadAll() {
this.busy = true;
try {
const [statsR, clientsR, filtersR] = await Promise.all([
Cockpit.api('/clients/stats'),
Cockpit.api('/clients'),
fetch('/api/retail/filters').then(r => r.json()).catch(() => ({})),
]);
this.stats = statsR.stats || {};
this.clients = clientsR.items || [];
this.chains = (filtersR.chains || []).map(x => x.chain || x).filter(Boolean);
this.provinces = (filtersR.provinces || []).map(x => x.province || x).filter(Boolean);
this.applyFilter();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.clients.filter(c => {
if (this.filterStage && c.stage !== this.filterStage) return false;
if (this.filterSector && c.sector !== this.filterSector) return false;
if (!q) return true;
return [c.name, c.contact, c.email, c.sector, c.notes].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openClientModal() {
this.clientForm = { id: null, name: '', contact: '', email: '', stage: 'intake', sector: '', mrr_estimate: '', notes: '' };
this.clientModal = true;
},
editClient(c) {
this.clientForm = {
id: c.id, name: c.name || '', contact: c.contact || '', email: c.email || '',
stage: c.stage || 'intake', sector: c.sector || '', mrr_estimate: c.mrr_estimate || '',
notes: c.notes || '',
};
this.clientModal = true;
this.drawer = false;
},
async saveClient() {
const body = {
name: this.clientForm.name.trim(),
contact: this.clientForm.contact || null,
email: this.clientForm.email || null,
stage: this.clientForm.stage,
sector: this.clientForm.sector || null,
mrr_estimate: this.clientForm.mrr_estimate ? parseFloat(this.clientForm.mrr_estimate) : null,
notes: this.clientForm.notes || null,
};
try {
if (this.clientForm.id) {
await Cockpit.api('/clients/' + this.clientForm.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('Klant opgeslagen', 'success');
this.clientModal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async removeClient(id) {
if (!Cockpit.confirmDelete('Klant definitief verwijderen?')) return;
try {
await Cockpit.api('/clients/' + id, { method: 'DELETE' });
Cockpit.toast('Klant verwijderd', 'info');
this.drawer = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async openDetail(c) {
try {
const d = await Cockpit.api('/clients/' + c.id + '/detail');
this.detail = d;
this.drawer = true;
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async quickStage(c) {
const body = {
name: c.name, contact: c.contact, email: c.email, stage: c.stage,
sector: c.sector, mrr_estimate: c.mrr_estimate, notes: c.notes,
};
try {
await Cockpit.api('/clients/' + c.id, { method: 'PUT', body: JSON.stringify(body) });
Cockpit.toast('Stage bijgewerkt', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async searchStores() {
this.storeBusy = true;
try {
const params = new URLSearchParams({ limit: '80', sort: 'name' });
if (this.storeQ) params.set('q', this.storeQ);
if (this.storeChain) params.set('chain', this.storeChain);
if (this.storeProvince) params.set('province', this.storeProvince);
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
this.storeResults = r.items || [];
if (this.storeResults.length && !this.selectedStore) {
await this.selectStore(this.storeResults[0].id);
}
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async selectStore(id) {
try {
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
this.selectedStore = r;
this.storeForm = {
address: r.address || '', postcode: r.postcode || '', city: r.city || '',
province: r.province || '', phone: r.phone || '', email: r.email || '',
website: r.website || '', manager_name: r.manager_name || r.profile_manager || '',
partnership_status: r.partnership_status || 'none',
halal_certified: !!r.halal_certified, has_halal_section: !!r.has_halal_section,
halal_certifier: r.halal_certifier || '',
};
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async saveStore() {
if (!this.selectedStore) return;
this.storeBusy = true;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.storeForm),
}).then(r => { if (!r.ok) throw new Error('Opslaan mislukt'); return r.json(); });
Cockpit.toast('Filiaaldata opgeslagen', 'success');
await this.selectStore(this.selectedStore.id);
await this.searchStores();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async linkStore() {
if (!this.selectedStore || !this.linkForm.client_id) return;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id + '/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: parseInt(this.linkForm.client_id, 10),
relationship_type: this.linkForm.relationship_type,
partnership_status: this.storeForm.partnership_status !== 'none' ? this.storeForm.partnership_status : 'prospect',
notes: this.linkForm.notes || null,
}),
}).then(r => { if (!r.ok) throw new Error('Koppelen mislukt'); return r.json(); });
Cockpit.toast('Filiaal gekoppeld aan klant', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
openStoreCreate() {
this.newStore = { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' };
this.storeCreateModal = true;
},
async createStore() {
try {
const r = await fetch('/api/retail/supermarkets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newStore),
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
Cockpit.toast('Filiaal toegevoegd', 'success');
this.storeCreateModal = false;
await this.searchStores();
if (r.item?.id) await this.selectStore(r.item.id);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async setStage(id, stage, name, email, notes) {
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
Cockpit.toast('Stage updated', 'success'); location.reload();
}
};
}
</script>
+151 -45
View File
@@ -2,14 +2,25 @@
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=8" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero">
<h1>Herman · Command Center <span class="live-badge">Live</span></h1>
<p class="hero-sub">CEO overzicht · klanten · briefing · retail · alles op een rij</p>
<p class="hero-sub">CEO overzicht · trending retail · briefing · platform links</p>
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
</div>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
@@ -24,6 +35,7 @@
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-sm" @click="editLayout = !editLayout" x-text="editLayout ? 'Klaar ordenen' : '⋮⋮ Ordenen'"></button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
@@ -32,29 +44,38 @@
</div>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="briefing-dashboard" class="hm-dash-layout">
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · CEO KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's</h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="executive">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Executive</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database · geen RSS — zie Marketing Hub</small></div>
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
<div id="briefing-executive-summary"></div>
</div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section>
<section class="hm-dash-section hm-dash-full">
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p>
</div>
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
<h4>📋 Herman activiteitenlog</h4>
<ul id="briefing-activity-log" class="hm-exec-list" style="margin:0;padding-left:1.1rem;font-size:0.82rem"></ul>
</div>
<div class="briefing-horizon-col">
<div class="horizon-card short">
<h4>⚡ Korte termijn</h4>
@@ -68,12 +89,14 @@
</div>
</section>
<section class="hm-dash-section">
<section class="hm-dash-section hm-widget-draggable" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col">
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Top retail kansen</h4><small><a href="/retail">Retail 360</a></small></div>
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub</a></small></div>
<div id="briefing-retail"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
@@ -86,72 +109,153 @@
</div>
</section>
<section class="hm-dash-section hm-dash-full">
<h3 class="hm-section-title">Data & analytics</h3>
<section class="hm-dash-section hm-dash-full hm-dash-analytics hm-widget-draggable" data-widget="analytics">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Analytics</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-panel">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div>
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div>
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small id="viz-label-agents">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents"></canvas></div>
<div id="viz-agents-alt" class="viz-alt"></div>
</div>
</div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="approvals" id="widget-approvals">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Goedkeuringen</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen <span class="live-badge" id="approvals-pending-badge" style="display:none">0 open</span></h3>
<div class="panel" style="background:transparent;border:none;padding:0">
<p class="page-tip">SysOps vraagt dagelijks toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>. Keur goed → actie wordt automatisch uitgevoerd.</p>
<div id="dash-approvals-list"></div>
</div>
</section>
<div class="hub-grid hm-dash-feed hm-widget-draggable" data-widget="feed">
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Feed</div>
<section class="panel">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
</section>
<div class="hub-grid hm-dash-feed">
<section class="panel">
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
<div id="agent-feed-live">
{% for ev in agent_feed[:12] %}
<div class="hub-feed-item">
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
{{ ev.title or ev.event_type }}
</div>
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
</div>
</section>
<section class="panel">
<h2>Wacht op OK</h2>
{% for item in approvals[:8] %}
<div class="approval-item"><span class="badge">{{ item.agent_name }}</span> {{ item.title or item.event_type }}</div>
{% else %}<p class="empty-state">Geen goedkeuringen 🎉</p>{% endfor %}
</section>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/briefing-charts.js?v=8"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=1"></script>
<script src="/static/js/briefing-charts.js?v=11"></script>
<script>
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() {
return {
loading: false, loadingMsg: 'Herman werkt…',
loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
_pollStop: null, _wsStop: null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
async init() {
window._dashboardVizMode = this._vizMode;
const prefs = await DashboardLayout.loadPrefs();
this._vizMode = prefs.global_viz_mode || 'neo-bars';
window._dashboardVizMode = this._vizMode;
DashboardLayout.applyOrder(document.getElementById('briefing-dashboard'), prefs.dashboard_layout || []);
DashboardLayout.initDrag(document.getElementById('briefing-dashboard'), (order) => DashboardLayout.saveLayout(order));
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
this._vizMode = mode;
window._dashboardVizMode = mode;
await DashboardLayout.saveViz(mode);
document.getElementById('viz-label-sentiment').textContent = mode;
document.getElementById('viz-label-agents').textContent = mode;
if (this._stats) BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, mode);
});
await this.refreshLive(false);
await this.loadApprovals();
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => this.refreshLive(false), 30000);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
});
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('dash-approvals-list');
const badge = document.getElementById('approvals-pending-badge');
if (!el) return;
const items = r.items || [];
if (badge) {
badge.style.display = items.length ? 'inline' : 'none';
badge.textContent = items.length + ' open';
}
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr class="'+(i.action_type==='maintenance_scan'?'approval-row-scan':'')+'"><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓ Goedkeuren</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table><p class="page-tip" style="margin-top:0.5rem"><a href="/agents?tab=approvals">Volledige queue →</a></p>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
},
async loadProjectsMini() {
try {
const r = await fetch('/api/projects?limit=5').then(x => x.json());
const el = document.getElementById('dash-projects-mini');
if (!el) return;
const items = r.items || [];
el.innerHTML = items.length ? items.map(p =>
'<a href="/projects?id='+p.id+'" class="beurs-mini-chip"><strong>'+p.name+'</strong> · '+(p.asset_count||0)+' assets</a>'
).join('') : '<p class="muted">Nog geen projecten — <a href="/projects">maak aan</a></p>';
} catch (e) {}
},
renderActivityLog(entries) {
const el = document.getElementById('briefing-activity-log');
if (!el) return;
el.innerHTML = (entries || []).length
? entries.slice(0, 12).map(e => '<li>' + e + '</li>').join('')
: '<li class="muted">Nog geen agent-acties vandaag — Herman documenteert automatisch.</li>';
},
renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini');
if (!el) return;
@@ -166,8 +270,10 @@ function dashboardBriefing() {
try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) {
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats);
this._stats = r.stats;
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats, this._vizMode);
this.renderClientsMini(r.stats);
this.renderActivityLog(r.stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
@@ -182,7 +288,7 @@ function dashboardBriefing() {
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
this.content = r.content || '';
this.createdAt = r.generated_at || new Date().toISOString();
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt);
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt, this._vizMode);
this.renderClientsMini(r.stats || {});
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
+113 -29
View File
@@ -2,12 +2,24 @@
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=9" />
<style>
.doc-neo-shell { --hm-cyan: #00e5ff; --hm-border: rgba(0, 229, 255, 0.12); --hm-panel: #0d1219; }
.doc-neo-shell .page-header h1 { font-size: 1.55rem; }
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
.doc-chart-panel { height: 140px; }
.doc-chart-panel canvas { max-height: 110px !important; }
.doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
@media (max-width: 900px) { .doc-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
.doc-neo-kpi {
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.1rem 0.65rem;
}
.doc-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: #94a3b8; }
.doc-neo-kpi-value { font-size: 1.65rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0; }
.doc-chart-grid { display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:1rem; }
@media (max-width: 800px) { .doc-chart-grid { grid-template-columns: 1fr; } }
.doc-chart-panel .hm-chart-wrap { height: 130px; }
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
.photo-card { border:1px solid rgba(148,163,184,.15); border-radius:10px; overflow:hidden; background:rgba(0,0,0,.2); }
.photo-card { border:1px solid rgba(0,229,255,0.12); border-radius:12px; overflow:hidden; background:rgba(13,18,25,0.95); }
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
@@ -15,17 +27,33 @@
</style>
{% endblock %}
{% block content %}
<div x-data="documentsDash()" x-init="init()">
<header class="page-header">
<h1>Documents &amp; Foto's — interactief dashboard</h1>
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p>
<div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
<header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>Documents &amp; Foto's <span class="live-badge">Neo</span></h1>
<p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
</header>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi" @click="filterSentiment=''"><div class="kpi-label">Documenten</div><div class="kpi-value" x-text="filteredDocs.length"></div></div>
<div class="kpi-card purple clickable-kpi"><div class="kpi-label">Totaal woorden</div><div class="kpi-value">{{ summary.total_words }}</div></div>
<div class="kpi-card green clickable-kpi"><div class="kpi-label">Foto's in DB</div><div class="kpi-value" x-text="photos.length"></div></div>
<div class="kpi-card orange clickable-kpi"><div class="kpi-label">Gem. sentiment</div><div class="kpi-value">{{ summary.avg_sentiment }}</div></div>
<div class="doc-neo-kpi-row">
<div class="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
<div class="doc-neo-kpi-label">Documenten</div>
<div class="doc-neo-kpi-value" x-text="filteredDocs.length"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Totaal woorden</div>
<div class="doc-neo-kpi-value">{{ summary.total_words }}</div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Foto's in DB</div>
<div class="doc-neo-kpi-value" x-text="photos.length"></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Gem. sentiment</div>
<div class="doc-neo-kpi-value">{{ summary.avg_sentiment }}</div>
<div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
<div class="doc-dash-filters panel">
@@ -46,14 +74,18 @@
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
</div>
<div class="grid-2" style="margin-top:1rem">
<section class="panel doc-chart-panel">
<h2>Top woorden <small class="muted">klik = filter</small></h2>
<canvas id="wordsChart" height="110"></canvas>
<div class="doc-chart-grid">
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="wordsChart"></canvas></div>
<div id="viz-words-alt" class="viz-alt"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
<section class="panel doc-chart-panel">
<h2>Sentiment <small class="muted">klik = filter</small></h2>
<canvas id="sentimentChart" height="110"></canvas>
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="sentimentChart"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
</div>
@@ -132,8 +164,18 @@
</div>
</section>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem" x-show="activePhoto">
<select class="form-input" x-model="linkProjectId" style="max-width:200px">
<option value="">Koppel aan project…</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" @click="savePhotoToProject()">Opslaan in project</button>
<a href="/projects" class="btn btn-sm">Alle projecten →</a>
</div>
<div class="detection-wrap">
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
<canvas id="detectionCanvas"></canvas>
@@ -172,14 +214,28 @@ function documentsDash() {
activeOcr: '',
activeItems: [],
detections: [],
linkProjectId: '',
cockpitProjects: [],
liveRefresh: false,
liveTimer: null,
init() {
this.applyFilters();
this.renderCharts();
this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos();
this.refreshShare(false);
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
},
async savePhotoToProject() {
if (!this.activePhoto || !this.linkProjectId) return Cockpit.toast('Kies een project', 'error');
try {
await Cockpit.api('/api/projects/photos/' + this.activePhoto + '/link', {
method: 'POST',
body: JSON.stringify({ project_id: parseInt(this.linkProjectId, 10) }),
});
Cockpit.toast('Foto gekoppeld aan project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
mergeShareItems(items) {
@@ -235,16 +291,44 @@ function documentsDash() {
});
},
renderCharts() {
if (wordsChartInst) wordsChartInst.destroy();
if (sentimentChartInst) sentimentChartInst.destroy();
renderCharts(mode) {
mode = mode || window.__vizMode || 'neo-bars';
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
const dash = this;
const wordLabels = topWords.slice(0, 10).map(w => w.lemma);
const wordValues = topWords.slice(0, 10).map(w => w.total_count);
const sentLabels = ['Positief', 'Neutraal', 'Negatief'];
const sentValues = [summary.positive, summary.neutral, summary.negative];
if (window.VizEngine && (mode === 'neo-equalizer' || mode === 'neo-cards' || mode === 'neo-table')) {
VizEngine.renderWidget(mode, {
canvas: document.getElementById('wordsChart'),
altContainer: document.getElementById('viz-words-alt'),
key: 'doc-words',
labels: wordLabels, values: wordValues,
rows: wordLabels.map((l, i) => ({ label: l, value: wordValues[i], raw: wordValues[i] })),
eqClass: 'hm-eq-cyan',
});
VizEngine.renderWidget(mode, {
canvas: document.getElementById('sentimentChart'),
altContainer: document.getElementById('viz-sentiment-alt'),
key: 'doc-sentiment',
labels: sentLabels, values: sentValues,
rows: sentLabels.map((l, i) => ({ label: l, value: sentValues[i], raw: sentValues[i] })),
eqClass: 'hm-eq-purple',
});
return;
}
document.getElementById('viz-words-alt').innerHTML = '';
document.getElementById('viz-sentiment-alt').innerHTML = '';
document.getElementById('wordsChart').style.display = '';
document.getElementById('sentimentChart').style.display = '';
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
type: 'bar',
type: mode === 'neo-rings' ? 'polarArea' : 'bar',
data: {
labels: topWords.slice(0, 10).map(w => w.lemma),
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
backgroundColor: 'rgba(232,168,56,.75)', borderWidth: 0 }]
backgroundColor: 'rgba(0,229,255,0.75)', borderWidth: 0, borderRadius: 6 }]
},
options: {
responsive: true, maintainAspectRatio: false,
@@ -255,11 +339,11 @@ function documentsDash() {
}
});
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
type: 'doughnut',
type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }]
backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false,
+13 -2
View File
@@ -3,9 +3,15 @@
<div x-data="hermanChat()" class="chat-layout">
<header class="page-header">
<h1>Herman</h1>
<p class="subtitle page-tip">Co-CEO orchestrator — routes your message to specialist agents.</p>
<p class="subtitle page-tip">Co-CEO orchestrator — delegeert naar specialist agents (packaging, design, …).</p>
</header>
<div class="card card-compact" style="margin-bottom:1rem;">
<h2 style="font-size:1rem;margin:0 0 .5rem;">Verpakking via Packaging agent</h2>
<p class="page-tip" style="margin:0 0 .5rem;">Start met <code>maak verpakking:</code> — de agent genereert het design, slaat op op NAS en rapporteert aan Herman.</p>
<p class="page-tip" style="margin:0;font-size:0.78rem">Voorbeeld: <em>maak verpakking: pouch 100×140 voor Halal Kip Curry project #5 halal-badge</em></p>
</div>
<div class="card card-compact" style="margin-bottom:1rem;">
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2>
<p class="page-tip" style="margin:0 0 .75rem;">Beschrijf wat je wilt zien. Op CPU duurt dit 13 minuten.</p>
@@ -30,7 +36,7 @@
{% endfor %}
</div>
<form class="chat-input" @submit.prevent="send">
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak foto: product op witte achtergrond)" autocomplete="off" />
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak verpakking: doos 120×80×40 voor Chai N Masala project #3)" autocomplete="off" />
<button class="btn btn-primary" type="submit" :disabled="loading">Send</button>
</form>
</div>
@@ -62,6 +68,11 @@ function hermanChat() {
html += '<img src="' + r.image_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />';
this.lastImage = r.image_url;
}
if (r.packaging_studio_url) {
html += '<p style="margin-top:.5rem"><a class="btn btn-sm" href="' + esc(r.packaging_studio_url) + '">📦 Open Packaging Studio</a>';
if (r.packaging_pdf_url) html += ' <a class="btn btn-sm btn-ghost" href="' + esc(r.packaging_pdf_url) + '" target="_blank">PDF</a>';
html += '</p>';
}
html += '</div>';
log.innerHTML += html;
this.message = '';
+75 -1
View File
@@ -1,4 +1,8 @@
{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
<header class="page-header">
@@ -9,9 +13,29 @@
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/projects">Projecten →</a>
</div>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Marketing data</h4><small>RSS · trends · kansen</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing"></canvas></div>
<div id="viz-marketing-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Publish jobs</h4><small>status verdeling</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing-publish"></canvas></div>
<div id="viz-marketing-publish-alt" class="viz-alt"></div>
</div>
</div>
</div>
<div class="live-feed-bar" x-show="dashboard.rss?.length">
<div class="ticker-track">
<template x-for="r in dashboard.rss" :key="r.id">
@@ -191,6 +215,12 @@
<textarea class="form-input" rows="5" x-model="publishForm.text" placeholder="Post tekst — UTF-8 emoji ondersteund 🥘✨" style="width:100%;margin-bottom:0.75rem"></textarea>
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
<select class="form-input" x-model="publishForm.project_id" style="max-width:220px">
<option value="">Opslaan in agent-project (auto)</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<label class="btn btn-secondary btn-sm">
📷 Foto uploaden
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
@@ -359,6 +389,8 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script>
function marketingHub() {
return {
@@ -369,8 +401,9 @@ function marketingHub() {
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [] },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
cockpitProjects: [],
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨‍🍳','💚','📁','🎯'],
promoForm: { chain: '', title: '', folder_path: '', description: '' },
_pollStop: null,
@@ -385,11 +418,16 @@ function marketingHub() {
async init() {
await this.loadDashboard();
await this.loadBookmarks();
await this.loadPublishHistory();
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
if (this.tab === 'regelgeving') await this.loadRegulations();
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
this.$nextTick(() => this.updateTabIndicator());
if (window.PageViz) {
window.PageViz.init('marketing', (mode) => this.renderViz(mode));
}
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
const el=document.getElementById('reco-list');
if(!el) return;
@@ -510,6 +548,7 @@ function marketingHub() {
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
},
@@ -540,8 +579,42 @@ function marketingHub() {
try {
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
this.publishHistory = r.items || [];
if (window.__marketingRenderViz) window.__marketingRenderViz(window.__vizMode || 'neo-bars');
} catch (e) { this.publishHistory = []; }
},
renderViz(mode) {
window.__vizMode = mode;
window.__marketingRenderViz = (m) => this.renderViz(m);
if (!window.VizEngine) return;
const d = this.dashboard || {};
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing'),
document.getElementById('viz-marketing-alt'),
'marketing-main', 'Marketing KPIs',
[
{ label: 'RSS', value: (d.rss || []).length, raw: (d.rss || []).length },
{ label: 'Trends', value: (d.trends || []).length, raw: (d.trends || []).length },
{ label: 'Kansen', value: (d.top_opportunities || []).length, raw: (d.top_opportunities || []).length },
{ label: 'Promo', value: this.promos.length, raw: this.promos.length },
],
'hm-eq-purple', VizEngine.MODES ? null : null
);
const statusMap = {};
(this.publishHistory || []).forEach(j => {
const s = j.status || 'unknown';
statusMap[s] = (statusMap[s] || 0) + 1;
});
const labels = Object.keys(statusMap);
const values = labels.map(k => statusMap[k]);
if (!labels.length) { labels.push('geen'); values.push(0); }
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing-publish'),
document.getElementById('viz-marketing-publish-alt'),
'marketing-publish', 'Publish status',
labels.map((l, i) => ({ label: l, value: values[i], raw: values[i] })),
'hm-eq-cyan'
);
},
publishResultSummary(j) {
const res = j.result || {};
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } }
@@ -573,6 +646,7 @@ function marketingHub() {
image_url: this.publishForm.image_url || null,
media_ids: this.publishForm.media_ids || [],
channels: this.publishForm.channels,
project_id: this.publishForm.project_id ? parseInt(this.publishForm.project_id, 10) : null,
};
const r = await fetch('/api/marketing/publish', {
method: 'POST',
+190 -89
View File
@@ -1,131 +1,232 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
<header class="ops-header">
<div class="ops-shell hm-neo" x-data="opsPage()" x-init="init()">
<header class="ops-header herman-hero">
<div>
<h1>IT Ops Infrastructure</h1>
<p class="ops-subtitle">Realtime Proxmox + service health topology</p>
<h1>IT Ops · Dell R340 <span class="live-badge">Live</span></h1>
<p class="hero-sub">PowerEdge R340 → Proxmox VE → VM 106 → online services · SysOps agent</p>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">Topology</button>
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</button>
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">Verversen</button>
<button class="btn btn-sm btn-pulse-green" @click="snapshot()" :disabled="loading">Snapshot</button>
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-purple">Goedkeuringen →</a>
</div>
</header>
<section class="ops-kpis">
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div>
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div>
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div>
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></div>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Maintenance severity</h4><small>open items</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-maint"></canvas></div>
<div id="viz-ops-maint-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Backup status</h4><small>Gitea commits</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-backup"></canvas></div>
<div id="viz-ops-backup-alt" class="viz-alt"></div>
</div>
</div>
</div>
<section class="ops-kpis hm-neo-kpi-row" style="grid-template-columns:repeat(5,1fr)">
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Health</div><div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Online nodes</div><div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? 0"></div><div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Services</div><div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div><div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Open maintenance</div><div class="hm-neo-kpi-value" x-text="maintenance.length"></div><div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Laatste backup</div><div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="lastBackupLabel()"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</section>
<div class="ops-panels-grid">
<section class="panel ops-side-panel">
<h2>Maintenance · SysOps</h2>
<p class="page-tip">Dagelijks scan — updates, schijf, Docker health</p>
<template x-if="!maintenance.length">
<p class="empty-state">Geen open items 🎉</p>
</template>
<div class="ops-maint-list">
<template x-for="n in maintenance" :key="n.id">
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
<strong x-text="n.title"></strong>
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="n.body" x-show="n.body"></p>
</div>
</template>
</div>
</section>
<section class="panel ops-side-panel">
<h2>Config backups · Gitea</h2>
<p class="page-tip">SysOps vraagt dagelijks goedkeuring — na ✓ push naar Gitea</p>
<template x-if="pendingBackup">
<div class="ops-maint-item sev-warning">
<strong>⏳ Wacht op goedkeuring</strong>
<p x-text="pendingBackup.title"></p>
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-green">Keur goed →</a>
</div>
</template>
<div class="ops-maint-list">
<template x-for="b in backups.slice(0,5)" :key="b.id">
<div class="ops-maint-item" :class="b.status==='success'?'sev-info':'sev-warning'">
<strong x-text="b.status + (b.commit_ref ? ' · '+b.commit_ref : '')"></strong>
<small x-text="(b.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="b.message"></p>
</div>
</template>
</div>
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="btn btn-sm" style="margin-top:0.5rem">Gitea repo →</a>
</section>
<section class="panel ops-side-panel" style="grid-column:1/-1">
<h2>SysOps verslag · activiteitenlog</h2>
<p class="page-tip">Elke deploy, Gitea-push en maintenance-scan — Herman leest dit in de dagelijkse briefing.</p>
<div class="ops-maint-list">
<template x-for="a in sysopsActivity" :key="a.id">
<div class="ops-maint-item" :class="a.status==='success'?'sev-info':'sev-warning'">
<strong x-text="a.title"></strong>
<small x-text="(a.action_type||'')+' · '+(a.created_at||'').substring(0,16).replace('T',' ')"></small>
<p x-text="a.body" x-show="a.body"></p>
<small x-show="a.commit_ref" style="color:#38bdf8" x-text="'commit '+a.commit_ref"></small>
</div>
</template>
</div>
<p x-show="!sysopsActivity.length" class="empty-state">Nog geen SysOps-activiteit gelogd.</p>
</section>
</div>
<section class="panel topology-card">
<svg viewBox="0 0 1200 600" class="topology-canvas">
<g class="topology-lines">
<template x-for="edge in edges" :key="edge.id">
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
</template>
</g>
<g>
<template x-for="edge in edges" :key="'pulse-'+edge.id">
<line class="pulse-line" :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
</template>
</g>
<template x-for="node in nodes" :key="node.id">
<g class="ops-node" :class="node.css" :transform="'translate('+node.x+','+node.y+')'">
<circle r="36"></circle>
<text text-anchor="middle" y="-2" x-text="node.label"></text>
<text class="sub" text-anchor="middle" y="16" x-text="node.status"></text>
</g>
</template>
</svg>
<div class="ops-legend">
<span><i class="dot dot-online"></i> Online / bereikbaar</span>
<span><i class="dot dot-degraded"></i> Onbekend</span>
<span><i class="dot dot-offline"></i> Offline</span>
<span>Klik service-node → opent URL</span>
</div>
<svg id="ops-topology-canvas" viewBox="0 0 1200 620" class="topology-canvas"></svg>
</section>
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
<p class="ops-meta">Last update: <span id="ops-meta-time"></span></p>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/ops-topology.js?v=3"></script>
<script>
function opsTopology() {
function opsPage() {
return {
loading: false,
summary: { counts: {} },
nodes: [],
edges: [],
lastUpdate: null,
meta: {},
maintenance: [],
backups: [],
sysopsActivity: [],
pendingBackup: null,
timer: null,
positions: {
'proxmox-host': { x: 600, y: 90 },
'vm105-hermes': { x: 340, y: 230 },
'vm106-command': { x: 860, y: 230 },
'svc-gitea': { x: 190, y: 390 },
'svc-ollama': { x: 480, y: 390 },
'svc-cockpit': { x: 700, y: 390 },
'svc-tools-api': { x: 860, y: 390 },
'svc-email-agent': { x: 1020, y: 390 }
renderViz(mode) {
if (!window.VizEngine) return;
const sevMap = {};
(this.maintenance || []).forEach(n => {
const s = n.severity || 'info';
sevMap[s] = (sevMap[s] || 0) + 1;
});
let sevRows = Object.keys(sevMap).map(k => ({ label: k, value: sevMap[k], raw: sevMap[k] }));
if (!sevRows.length) sevRows = [{ label: 'ok', value: 0, raw: 0 }];
VizEngine.renderFromRows(mode,
document.getElementById('chart-ops-maint'),
document.getElementById('viz-ops-maint-alt'),
'ops-maint', 'Maintenance', sevRows, 'hm-eq-purple'
);
const stMap = {};
(this.backups || []).forEach(b => {
const s = b.status || 'unknown';
stMap[s] = (stMap[s] || 0) + 1;
});
let stRows = Object.keys(stMap).map(k => ({ label: k, value: stMap[k], raw: stMap[k] }));
if (!stRows.length) stRows = [{ label: 'geen', value: 0, raw: 0 }];
VizEngine.renderFromRows(mode,
document.getElementById('chart-ops-backup'),
document.getElementById('viz-ops-backup-alt'),
'ops-backup', 'Backups', stRows, 'hm-eq-cyan'
);
},
init() {
this.loadStatus();
this.loadTopology();
this.timer = setInterval(() => this.loadTopology(), 30000);
async init() {
await this.loadAll();
await this.draw();
if (window.PageViz) {
window.PageViz.init('ops', (mode) => this.renderViz(mode));
}
this.timer = setInterval(() => this.refresh(false), 30000);
},
lastBackupLabel() {
const ok = this.backups.find(b => b.status === 'success');
if (!ok) return '—';
return (ok.commit_ref || ok.status) + ' · ' + (ok.created_at || '').substring(0, 10);
},
async loadAll() {
await Promise.all([this.loadStatus(), this.loadMaintenance(), this.loadBackups(), this.loadSysopsActivity(), this.loadPendingApproval()]);
this.renderViz(window.__vizMode || 'neo-bars');
},
async loadStatus() {
try {
const data = await fetch('/api/ops/status').then(r => r.json());
this.summary = data || { counts: {} };
this.lastUpdate = data.generated_at || new Date().toISOString();
} catch (e) {
console.warn(e);
this.meta = (data.topology && data.topology.meta) || data.sources || {};
} catch (e) { console.warn(e); }
},
async loadMaintenance() {
try {
const r = await fetch('/api/ops/maintenance?limit=12').then(x => x.json());
this.maintenance = (r.items || []).filter(n => !n.resolved);
} catch (e) { this.maintenance = []; }
},
async loadBackups() {
try {
const r = await fetch('/api/ops/backups?limit=8').then(x => x.json());
this.backups = r.items || [];
} catch (e) { this.backups = []; }
},
async loadSysopsActivity() {
try {
const r = await fetch('/api/ops/sysops-activity?limit=20').then(x => x.json());
this.sysopsActivity = r.items || [];
} catch (e) { this.sysopsActivity = []; }
},
async loadPendingApproval() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=20').then(x => x.json());
this.pendingBackup = (r.items || []).find(i => i.agent_key === 'sysops' && i.action_type === 'config_backup') || null;
} catch (e) { this.pendingBackup = null; }
},
async draw() {
if (window.OpsTopology) {
await window.OpsTopology.render('ops-topology-canvas', document.getElementById('ops-meta-time'));
}
},
async loadTopology() {
async refresh(toast) {
this.loading = true;
try {
const data = await fetch('/api/ops/topology').then(r => r.json());
this.toGraph(data);
await this.loadStatus();
} catch (e) {
console.warn(e);
} finally {
this.loading = false;
}
await this.loadAll();
await this.draw();
if (toast !== false && window.Cockpit) Cockpit.toast('IT Ops bijgewerkt', 'success');
} finally { this.loading = false; }
},
async refreshInfra() {
async snapshot() {
this.loading = true;
try {
await fetch('/api/ops/refresh', { method: 'POST' });
await this.loadTopology();
await this.refresh(false);
if (window.Cockpit) Cockpit.toast('Infra snapshot opgeslagen', 'success');
} catch (e) {
console.warn(e);
} finally {
this.loading = false;
}
if (window.Cockpit) Cockpit.toast('Snapshot mislukt', 'error');
} finally { this.loading = false; }
},
toGraph(payload) {
const outNodes = [];
const outEdges = [];
const flatten = (n, parentId = null) => {
const pos = this.positions[n.id] || { x: 80 + outNodes.length * 120, y: 520 };
const status = (n.status || 'unknown').toLowerCase();
const kind = (n.type || 'service').toLowerCase();
const css = `node-${kind} status-${status}`;
outNodes.push({ id: n.id, label: n.label, status: n.status || 'unknown', x: pos.x, y: pos.y, css });
if (parentId) {
const p = this.positions[parentId];
if (p) {
outEdges.push({ id: `${parentId}-${n.id}`, x1: p.x, y1: p.y + 36, x2: pos.x, y2: pos.y - 36 });
}
}
(n.children || []).forEach(child => flatten(child, n.id));
};
(payload.nodes || []).forEach(root => flatten(root, null));
this.nodes = outNodes;
this.edges = outEdges;
this.lastUpdate = payload.generated_at || new Date().toISOString();
}
};
}
</script>
+333 -105
View File
@@ -1,92 +1,173 @@
{% extends "base.html" %}
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
{% block extra_head %}
<style>
.pack-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
@media (max-width: 1024px) { .pack-grid { grid-template-columns: 1fr; } }
.pack-preview {
min-height: 460px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(0,229,255,.08), rgba(0,0,0,.35));
border: 1px solid rgba(0,229,255,.2);
border-radius: 12px;
overflow: auto;
padding: 1rem;
}
.pack-preview svg { width: 100%; max-height: 72vh; }
.pack-tools { display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.75rem; }
.elements-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; }
.project-list { max-height: 260px; overflow: auto; display:flex; flex-direction:column; gap:.5rem; }
.project-item { border:1px solid rgba(148,163,184,.15); border-radius:10px; padding:.6rem; background:rgba(0,0,0,.25); }
.project-item small { color: var(--text-secondary); display:block; margin-top:.25rem; }
</style>
<link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="packagingStudio()" x-init="init()">
<header class="page-header">
<h1>Packaging Studio</h1>
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p>
<div class="pack-studio" x-data="packagingStudio()" x-init="init()">
<header class="pack-hero">
<div>
<h1>📦 Packaging Studio</h1>
<p>Ontwerp verpakkingen met Python SVG-engine · koppel aan project · export naar NAS · email &amp; kopieer naar ander project.</p>
</div>
<div class="pack-toolbar">
<button class="btn btn-sm" @click="preview()" :disabled="loading">👁 Preview</button>
<button class="btn btn-primary btn-sm" @click="save()" :disabled="loading">
<span x-text="packagingId ? '💾 Bijwerken' : '💾 Opslaan'"></span>
</button>
<button class="btn btn-sm" @click="duplicate()" x-show="packagingId" :disabled="loading">📋 Dupliceer</button>
<button class="btn btn-sm" @click="showCopyModal=true" x-show="packagingId">📁 Kopieer naar project</button>
<button class="btn btn-sm" @click="showEmailModal=true" x-show="packagingId">✉ Email</button>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('svg')" x-show="packagingId">SVG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('png')" x-show="packagingId">PNG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('pdf')" x-show="packagingId">PDF</a>
<button class="btn btn-sm btn-ghost" @click="newDesign()"> Nieuw</button>
</div>
</header>
<div class="pack-grid">
<section class="panel">
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;">
<label>Type
<select class="form-input" x-model="form.type">
<option value="folding_box">Folding box</option>
<option value="wrap">Wrap</option>
<option value="round_label">Round label</option>
</select>
</label>
<label>Barcode value
<input class="form-input" x-model="form.barcode_value" placeholder="8710000000012" />
</label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" /></label>
<div class="pack-layout">
<aside class="pack-panel">
<div class="pack-tabs">
<button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
<button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
<button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
<button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
<button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
</div>
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2>
<div class="elements-grid">
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label>
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label>
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label>
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</label>
<div x-show="sideTab==='shape'">
<h3>Afmetingen &amp; type</h3>
<div class="pack-field-grid">
<label class="full">Design naam
<input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
<label class="full">Type
<select class="form-input" x-model="form.type">
<template x-for="t in types" :key="t.id">
<option :value="t.id" x-text="t.label"></option>
</template>
</select></label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" @input="debouncedPreview()" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" @input="debouncedPreview()" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" @input="debouncedPreview()" /></label>
<label>Bleed (mm)<input type="number" class="form-input" x-model.number="form.bleed_mm" min="0" max="20" @input="debouncedPreview()" /></label>
<label class="full">Barcode<input class="form-input" x-model="form.barcode_value" @input="debouncedPreview()" /></label>
<label class="full">QR URL<input class="form-input" x-model="form.qr_value" placeholder="https://foodlinkk.nl/product/..." @input="debouncedPreview()" /></label>
</div>
</div>
<div class="pack-tools">
<button class="btn btn-primary" @click="generate()" :disabled="loading">
<span x-show="!loading">Genereer verpakking</span>
<span x-show="loading">Genereren…</span>
</button>
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a>
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a>
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a>
<div x-show="sideTab==='brand'" x-cloak>
<h3>Kleuren &amp; stijl</h3>
<div class="pack-field-grid">
<template x-for="c in colorFields" :key="c.key">
<label class="full" x-text="c.label">
<div class="pack-color-row">
<input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
<input type="color" :value="form.brand[c.key]" @input="form.brand[c.key]=$event.target.value; debouncedPreview()" />
</div>
</label>
</template>
</div>
</div>
<div x-show="sideTab==='text'" x-cloak>
<h3>Producttekst</h3>
<div class="pack-field-grid">
<label class="full">Productnaam<input class="form-input" x-model="form.text.product_name" @input="debouncedPreview()" /></label>
<label class="full">Tagline<input class="form-input" x-model="form.text.tagline" @input="debouncedPreview()" /></label>
<label>Netto gewicht<input class="form-input" x-model="form.text.subtitle" placeholder="400g" @input="debouncedPreview()" /></label>
<label>Herkomst<input class="form-input" x-model="form.text.origin" @input="debouncedPreview()" /></label>
<label class="full">Ingrediënten<textarea class="form-input" rows="3" x-model="form.text.ingredients" @input="debouncedPreview()"></textarea></label>
<label class="full">THT / best before<input class="form-input" x-model="form.text.best_before" @input="debouncedPreview()" /></label>
</div>
</div>
<div x-show="sideTab==='elements'" x-cloak>
<h3>Print &amp; productie-elementen</h3>
<div class="pack-elements">
<template x-for="el in elementList" :key="el.key">
<label><input type="checkbox" x-model="form.elements[el.key]" @change="debouncedPreview()"> <span x-text="el.label"></span></label>
</template>
</div>
</div>
<div x-show="sideTab==='project'" x-cloak>
<h3>Project &amp; NAS</h3>
<div class="pack-field-grid">
<label class="full">Bestaand project
<select class="form-input" x-model="save.project_id">
<option value="">— Nieuw project —</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<label class="full" x-show="!save.project_id">Nieuw projectnaam
<input class="form-input" x-model="save.project_name" placeholder="Jumbo pouch Q3" /></label>
<label class="full" x-show="!save.project_id">Klant
<select class="form-input" x-model="save.client_id">
<option value=""></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
</div>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">Bij opslaan: SVG, PNG, PDF + manifest → <code>…/packaging/</code> op NAS.</p>
<div x-show="nasInfo.ok" class="pack-nas-badge" style="margin-top:0.5rem">✓ NAS export OK</div>
<div class="muted" style="font-size:0.68rem;margin-top:0.35rem;word-break:break-all" x-show="nasInfo.files?.svg" x-text="nasInfo.files?.svg"></div>
</div>
</aside>
<section class="pack-panel pack-preview-wrap">
<h3>Live preview</h3>
<div class="pack-preview-stage">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Klik Preview of pas instellingen aan.</p>
<p class="page-tip" x-show="loading">Genereren…</p>
</div>
<div class="pack-meta-bar">
<span x-text="packagingId ? 'ID: '+packagingId.substring(0,8)+'…' : 'Nog niet opgeslagen'"></span>
<span x-text="form.type + ' · ' + form.width_mm + '×' + form.height_mm + '×' + form.depth_mm + ' mm'"></span>
</div>
</section>
<aside class="panel">
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2>
<div class="project-list">
<template x-for="p in projects" :key="p.id">
<div class="project-item">
<strong x-text="p.spec.type"></strong>
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small>
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small>
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button>
</div>
<aside class="pack-panel">
<h3>Bibliotheek</h3>
<div class="pack-library">
<template x-for="p in library" :key="p.id">
<article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
<strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
<small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
<small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
</article>
</template>
<p class="page-tip" x-show="!projects.length">Nog geen packaging projecten.</p>
<p class="page-tip" x-show="!library.length">Nog geen designs opgeslagen.</p>
</div>
</aside>
</div>
<div class="panel" style="margin-top:1rem;">
<div class="pack-preview">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p>
<div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
<div class="pack-modal" x-show="showEmailModal" x-cloak>
<h3>✉ Design emailen</h3>
<label>Aan (komma-gescheiden)<input class="form-input" x-model="emailForm.to" placeholder="klant@example.com" /></label>
<label style="margin-top:0.5rem;display:block">Onderwerp<input class="form-input" x-model="emailForm.subject" /></label>
<label style="margin-top:0.5rem;display:block">Bericht<textarea class="form-input" rows="4" x-model="emailForm.body"></textarea></label>
<p class="page-tip" style="font-size:0.7rem;margin-top:0.5rem">Downloadlinks voor SVG, PNG en PDF worden automatisch toegevoegd.</p>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showEmailModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="sendEmail()">Verstuur</button>
</div>
</div>
<div class="pack-modal-backdrop" x-show="showCopyModal" x-cloak @click="showCopyModal=false"></div>
<div class="pack-modal" x-show="showCopyModal" x-cloak>
<h3>📁 Kopieer naar ander project</h3>
<label>Doelproject
<select class="form-input" x-model="copyTargetId">
<option value="">— Selecteer —</option>
<template x-for="p in cockpitProjects" :key="'cp'+p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showCopyModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="copyToProject()">Kopieer</button>
</div>
</div>
</div>
@@ -94,58 +175,205 @@
{% block scripts %}
<script>
function packagingStudio() {
const DEFAULT_BRAND = {
bg: '#0b1220', panel: '#101a2d', primary: '#00e5ff', secondary: '#ffd700',
accent: '#b8ff3c', text: '#e2e8f0', muted: '#94a3b8', cut_line: '#ef4444',
fold_line: '#60a5fa', bleed: '#f97316', halal: '#22c55e'
};
const 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
};
return {
loading: false,
sideTab: 'shape',
svg: '',
projectId: '',
projects: [],
packagingId: '',
library: [],
cockpitProjects: [],
clients: [],
types: [],
nasInfo: {},
showEmailModal: false,
showCopyModal: false,
copyTargetId: '',
previewTimer: null,
save: { project_name: '', client_id: '', project_id: '{{ initial_project_id or "" }}' },
emailForm: { to: '', subject: 'Foodlinkk packaging design', body: 'Bijgevoegd vind je ons packaging design.' },
colorFields: [
{ key: 'primary', label: 'Primair' }, { key: 'secondary', label: 'Secundair' },
{ key: 'accent', label: 'Accent' }, { key: 'panel', label: 'Paneel' },
{ key: 'text', label: 'Tekst' }, { key: 'bg', label: 'Achtergrond' }
],
elementList: [
{ key: 'logo_area', label: 'Logo zone' }, { key: 'barcode', label: 'Barcode' },
{ key: 'qr_code', label: 'QR code' }, { key: 'nutrition_panel', label: 'Voedingswaarden' },
{ key: 'ingredients', label: 'Ingrediënten' }, { key: 'halal_badge', label: 'Halal badge' },
{ key: 'window', label: 'Venster' }, { key: 'fold_lines', label: 'Vouwlijnen' },
{ key: 'cut_lines', label: 'Snijlijnen' }, { key: 'glue_tabs', label: 'Lijmtabs' },
{ key: 'bleed', label: 'Bleed marge' }, { key: 'dimensions', label: 'Afmetingen' }
],
form: {
type: 'folding_box',
width_mm: 120,
height_mm: 80,
depth_mm: 40,
barcode_value: '8710000000012',
brand: {},
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true }
width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
barcode_value: '8710000000012', qr_value: '',
design_name: 'Nieuw design',
brand: { ...DEFAULT_BRAND },
text: {
product_name: 'FOODLINKK Premium',
tagline: 'Authentiek · Vers · Halal',
subtitle: '400g', origin: 'Geproduceerd in NL',
ingredients: 'Kip 45%, water, specerijen, zout, ui, knoflook.',
best_before: 'Ten minste houdbaar tot: zie verpakking'
},
elements: { ...DEFAULT_ELEMENTS }
},
async init() { await this.refreshProjects(); },
downloadUrl(format) {
if (!this.projectId) return '#';
return '/api/packaging/download/' + this.projectId + '?format=' + format;
},
async refreshProjects() {
async init() {
await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
try {
const r = await Cockpit.api('/api/packaging/projects?limit=20');
this.projects = r.items || [];
const r = await Cockpit.api('/clients');
this.clients = r.clients || r.items || [];
} catch (e) { this.clients = []; }
const pid = new URLSearchParams(location.search).get('project_id') || this.save.project_id;
if (pid) this.save.project_id = String(pid);
await this.preview();
},
async loadTypes() {
try {
const r = await fetch('/api/packaging/types').then(x => x.json());
this.types = r.items || [];
} catch (e) {
this.projects = [];
this.types = [
{ id: 'folding_box', label: 'Folding box' }, { id: 'wrap', label: 'Wrap' },
{ id: 'round_label', label: 'Rond label' }, { id: 'sleeve', label: 'Sleeve' },
{ id: 'pouch', label: 'Pouch' }, { id: 'tray', label: 'Tray' }
];
}
},
async loadProject(id) {
this.projectId = id;
try {
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text());
this.svg = data;
} catch (e) {
Cockpit.toast('Kon project niet laden', 'error');
payload() {
const body = JSON.parse(JSON.stringify(this.form));
if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
else if (this.save.project_name) {
body.project_name = this.save.project_name;
if (this.save.client_id) body.client_id = parseInt(this.save.client_id, 10);
}
return body;
},
async generate() {
debouncedPreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.preview(), 450);
},
async preview() {
if (this.loading) return;
this.loading = true;
try {
const r = await Cockpit.api('/api/packaging/generate', {
method: 'POST',
body: JSON.stringify(this.form)
});
const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
this.svg = r.svg || '';
this.projectId = r.id || '';
await this.refreshProjects();
Cockpit.toast('Packaging gegenereerd', 'success');
} catch (e) {
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
}
} catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
this.loading = false;
},
async save() {
if (this.loading) return;
this.loading = true;
try {
const url = this.packagingId ? '/api/packaging/' + this.packagingId : '/api/packaging/generate';
const method = this.packagingId ? 'PATCH' : 'POST';
const r = await Cockpit.api(url, { method, body: JSON.stringify(this.payload()) });
this.svg = r.svg || this.svg;
this.packagingId = r.id || this.packagingId;
this.nasInfo = r.nas || {};
await this.refreshLibrary();
await this.loadCockpitProjects();
Cockpit.toast(this.packagingId ? 'Design bijgewerkt' : 'Design opgeslagen', 'success');
} catch (e) { Cockpit.toast(e.message || 'Opslaan mislukt', 'error'); }
this.loading = false;
},
async duplicate() {
if (!this.packagingId) return;
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/duplicate', { method: 'POST', body: '{}' });
await this.loadDesign(r.id);
Cockpit.toast('Design gedupliceerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async copyToProject() {
if (!this.packagingId || !this.copyTargetId) return Cockpit.toast('Selecteer doelproject', 'warn');
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/copy-to-project', {
method: 'POST',
body: JSON.stringify({ target_project_id: parseInt(this.copyTargetId, 10) })
});
this.showCopyModal = false;
await this.loadDesign(r.id);
Cockpit.toast('Gekopieerd naar project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async sendEmail() {
if (!this.packagingId) return;
const to = (this.emailForm.to || '').split(',').map(s => s.trim()).filter(Boolean);
if (!to.length) return Cockpit.toast('Vul ontvanger in', 'warn');
try {
await Cockpit.api('/api/packaging/' + this.packagingId + '/email', {
method: 'POST',
body: JSON.stringify({ ...this.emailForm, to })
});
this.showEmailModal = false;
Cockpit.toast('Email verstuurd', 'success');
} catch (e) { Cockpit.toast(e.message || 'Email mislukt — check Settings → Email', 'error'); }
},
newDesign() {
this.packagingId = '';
this.nasInfo = {};
this.form.design_name = 'Nieuw design';
this.preview();
},
downloadUrl(format) {
return this.packagingId ? '/api/packaging/download/' + this.packagingId + '?format=' + format : '#';
},
async refreshLibrary() {
try {
const r = await Cockpit.api('/api/packaging/projects?limit=40');
this.library = r.items || [];
} catch (e) { this.library = []; }
},
async loadCockpitProjects() {
try {
const r = await fetch('/api/projects?limit=80').then(x => x.json());
this.cockpitProjects = r.items || [];
} catch (e) { this.cockpitProjects = []; }
},
async loadDesign(id) {
try {
const r = await Cockpit.api('/api/packaging/' + id);
const item = r.item || {};
const spec = item.spec || {};
this.packagingId = item.id;
this.svg = item.svg || '';
if (spec.type) this.form.type = spec.type;
['width_mm','height_mm','depth_mm','bleed_mm','barcode_value','qr_value','design_name'].forEach(k => {
if (spec[k] !== undefined) this.form[k] = spec[k];
});
this.form.brand = { ...DEFAULT_BRAND, ...(spec.brand || {}) };
this.form.text = { ...this.form.text, ...(spec.text || {}) };
this.form.elements = { ...DEFAULT_ELEMENTS, ...(spec.elements || {}) };
if (item.cockpit_project_id) this.save.project_id = String(item.cockpit_project_id);
Cockpit.toast('Design geladen', 'info');
} catch (e) { Cockpit.toast('Laden mislukt', 'error'); }
}
};
}
+247 -27
View File
@@ -1,40 +1,260 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="productsCrud()">
<header class="page-header"><h1>Products</h1><p class="subtitle page-tip">SKU catalog with margin calculator.</p></header>
<button class="btn btn-primary" @click="show=true; id=null">Add product</button>
<section class="panel"><h2>Margin calculator</h2>
<div class="form-grid"><input type="number" class="form-input" x-model="margin.cost" placeholder="Cost" />
<input type="number" class="form-input" x-model="margin.sell" placeholder="Sell" />
<button class="btn btn-secondary" @click="calcMargin()">Calculate</button></div>
<p x-show="margin.result">Margin: <strong x-text="margin.result"></strong>%</p></section>
<table class="data-table"><thead><tr><th>Name</th><th>Status</th><th>Margin %</th><th></th></tr></thead>
<tbody>{% for p in products %}<tr class="clickable-row" @click="edit({{ p.id }}, '{{ p.name|e }}', '{{ p.status|e }}', {{ p.margin_pct or 0 }})">
<td>{{ p.name }}</td><td>{{ p.status }}</td><td>{{ p.margin_pct }}</td>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ p.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.status" /><input type="number" class="form-input" x-model="form.margin_pct" />
<button class="btn btn-primary" @click="save()">Save</button></div></div>
<div class="clients-shell" x-data="productsHub()" x-init="init()" @foodlinkk:locale.window="onLocale()">
<header class="page-header">
<div>
<h1 x-text="$t('products.title')">Productcatalogus</h1>
<p class="subtitle page-tip" x-text="$t('products.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<a class="btn btn-sm" href="/clients" x-text="$t('nav.clients')"></a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span x-text="$t('products.total')"></span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.active')"></span><strong x-text="stats.active||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.avg_margin')"></span><strong x-text="(stats.avg_margin||0)+'%'"></strong></div>
<div class="clients-kpi"><span x-text="$t('products.with_client')"></span><strong x-text="stats.with_client||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('products.tab_grid')"></button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('products.tab_list')"></button>
<button class="clients-tab" :class="{active: tab==='margin'}" @click="tab='margin'" x-text="$t('products.tab_margin')"></button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('products.new')"></button>
</div>
</div>
<div x-show="tab==='grid'" x-cloak>
<div class="agents-neo-grid">
<template x-for="p in filtered" :key="p.id">
<article class="agent-soul-card" @click="openDetail(p)">
<strong x-text="p.name"></strong>
<div class="clients-card-meta">
<span class="clients-tag" x-text="statusLabel(p.status)"></span>
<span class="clients-tag mrr" x-show="p.margin_pct" x-text="(p.margin_pct||0)+'%'"></span>
</div>
<div class="clients-card-meta" x-show="p.client_name" x-text="p.client_name"></div>
<div class="clients-card-meta" x-show="p.shelf_target" x-text="p.shelf_target"></div>
</article>
</template>
</div>
<p class="clients-empty" x-show="!filtered.length" x-text="$t('products.no_products')"></p>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterStatus" @change="applyFilter()">
<option value="" x-text="$t('common.all')"></option>
<template x-for="s in statuses" :key="s.id">
<option :value="s.id" x-text="s.label"></option>
</template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th x-text="$t('products.name')"></th><th x-text="$t('products.client')"></th>
<th x-text="$t('products.status')"></th><th x-text="$t('products.margin_pct')"></th>
<th x-text="$t('products.moq')"></th><th></th>
</tr></thead>
<tbody>
<template x-for="p in filtered" :key="'l'+p.id">
<tr class="clickable-row" @click="openDetail(p)">
<td><strong x-text="p.name"></strong></td>
<td x-text="p.client_name||'—'"></td>
<td><span class="badge" x-text="statusLabel(p.status)"></span></td>
<td x-text="p.margin_pct!=null?p.margin_pct+'%':'—'"></td>
<td x-text="p.moq||'—'"></td>
<td>
<button class="btn btn-sm" @click.stop="editProduct(p)" x-text="$t('common.edit')"></button>
<button class="btn btn-sm btn-reject" @click.stop="remove(p.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab==='margin'" x-cloak>
<section class="panel store-edit-panel">
<div class="clients-form-grid" style="max-width:480px">
<label><span class="filter-label" x-text="$t('products.calc_cost')"></span>
<input type="number" class="form-input" x-model="margin.cost" step="0.01" /></label>
<label><span class="filter-label" x-text="$t('products.calc_sell')"></span>
<input type="number" class="form-input" x-model="margin.sell" step="0.01" /></label>
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:0.75rem" @click="calcMargin()" x-text="$t('products.calc_run')"></button>
<div x-show="margin.result!=null" style="margin-top:1rem">
<p><span x-text="$t('products.calc_margin')"></span>: <strong x-text="margin.result+'%'"></strong></p>
<p x-show="margin.profit!=null"><span x-text="$t('products.calc_profit')"></span>: <strong x-text="'€'+margin.profit"></strong></p>
</div>
</section>
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="form.id ? $t('common.edit') : $t('products.new')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label" x-text="$t('products.name')"></span>
<input class="form-input" x-model="form.name" /></label>
<label><span class="filter-label" x-text="$t('products.status')"></span>
<select class="form-input" x-model="form.status">
<template x-for="s in statuses" :key="'fs'+s.id"><option :value="s.id" x-text="s.label"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('products.client')"></span>
<select class="form-input" x-model="form.client_id">
<option value="" x-text="$t('common.none')"></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('products.margin_pct')"></span>
<input type="number" class="form-input" x-model="form.margin_pct" step="0.1" /></label>
<label><span class="filter-label" x-text="$t('products.moq')"></span>
<input type="number" class="form-input" x-model="form.moq" /></label>
<label><span class="filter-label" x-text="$t('products.launch_date')"></span>
<input type="date" class="form-input" x-model="form.launch_date" /></label>
<label class="full"><span class="filter-label" x-text="$t('products.shelf_target')"></span>
<input class="form-input" x-model="form.shelf_target" placeholder="Jumbo NL · AH regio" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
</div>
</div>
</div>
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div><h2 x-text="detail?.product?.name||''"></h2>
<p class="muted" x-text="statusLabel(detail?.product?.status)"></p></div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.product">
<div class="clients-card-meta">
<div x-show="detail.product.client_name"><strong x-text="$t('products.drawer_deals')"></strong>: <span x-text="detail.product.client_name"></span></div>
<div x-show="detail.product.margin_pct"><strong x-text="$t('products.margin_pct')"></strong>: <span x-text="detail.product.margin_pct+'%'"></span></div>
<div x-show="detail.product.moq"><strong x-text="$t('products.moq')"></strong>: <span x-text="detail.product.moq"></span></div>
<div x-show="detail.product.shelf_target"><strong x-text="$t('products.shelf_target')"></strong>: <span x-text="detail.product.shelf_target"></span></div>
<div x-show="detail.product.launch_date"><strong x-text="$t('products.launch_date')"></strong>: <span x-text="detail.product.launch_date"></span></div>
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editProduct(detail.product)" x-text="$t('common.edit')"></button>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function productsCrud() {
function productsHub() {
return {
show: false, id: null, form: { name: '', status: 'concept', margin_pct: 0, client_id: null },
margin: { cost: 0, sell: 0, result: null },
edit(id, name, status, margin_pct) { this.id=id; this.form={name,status,margin_pct,client_id:null}; this.show=true; },
async calcMargin() {
const r = await Cockpit.api('/products/margin-calc', { method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }) });
this.margin.result = r.margin_pct;
tab: 'grid', busy: false, modal: false, drawer: false,
stats: {}, products: [], filtered: [], clients: [], detail: null,
filterQ: '', filterStatus: '',
form: {}, margin: { cost: 0, sell: 0, result: null, profit: null },
statuses: [],
onLocale() { this.buildStatuses(); },
buildStatuses() {
this.statuses = [
{ id: 'concept', label: this.$t('products.status_concept') },
{ id: 'development', label: this.$t('products.status_development') },
{ id: 'active', label: this.$t('products.status_active') },
{ id: 'discontinued', label: this.$t('products.status_discontinued') },
];
},
statusLabel(id) {
return (this.statuses.find(s => s.id === id) || {}).label || id || '—';
},
async init() {
this.buildStatuses();
document.addEventListener('foodlinkk:locale', () => this.buildStatuses());
await this.loadAll();
},
async loadAll() {
this.busy = true;
try {
const [statsR, prodR, cliR] = await Promise.all([
Cockpit.api('/products/stats'),
Cockpit.api('/products'),
Cockpit.api('/clients'),
]);
this.stats = statsR.stats || {};
this.products = prodR.items || [];
this.clients = cliR.items || [];
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.products.filter(p => {
if (this.filterStatus && p.status !== this.filterStatus) return false;
if (!q) return true;
return [p.name, p.client_name, p.shelf_target, p.status].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openModal() {
this.form = { id: null, name: '', status: 'concept', client_id: '', margin_pct: '', moq: '', shelf_target: '', launch_date: '' };
this.modal = true;
},
editProduct(p) {
this.form = {
id: p.id, name: p.name || '', status: p.status || 'concept',
client_id: p.client_id || '', margin_pct: p.margin_pct ?? '',
moq: p.moq ?? '', shelf_target: p.shelf_target || '', launch_date: p.launch_date || '',
};
this.modal = true; this.drawer = false;
},
async save() {
const body = { ...this.form, margin_pct: parseFloat(this.form.margin_pct) };
if (this.id) await Cockpit.api('/products/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
location.reload();
const body = {
name: this.form.name.trim(),
status: this.form.status,
client_id: this.form.client_id ? parseInt(this.form.client_id, 10) : null,
margin_pct: this.form.margin_pct !== '' ? parseFloat(this.form.margin_pct) : null,
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
shelf_target: this.form.shelf_target || null,
launch_date: this.form.launch_date || null,
};
try {
if (this.form.id) await Cockpit.api('/products/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
Cockpit.toast(window.I18n.t('common.saved'), 'success');
this.modal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!Cockpit.confirmDelete()) return;
await Cockpit.api('/products/' + id, { method: 'DELETE' });
await this.loadAll();
},
async openDetail(p) {
const d = await Cockpit.api('/products/' + p.id + '/detail');
this.detail = d;
this.drawer = true;
},
async calcMargin() {
const r = await Cockpit.api('/products/margin-calc', {
method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }),
});
this.margin.result = r.margin_pct;
this.margin.profit = r.profit;
},
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/products/' + id, { method: 'DELETE' }); location.reload(); }
};
}
</script>
+344
View File
@@ -0,0 +1,344 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
<link rel="stylesheet" href="/static/css/projects-hub.css?v=1" />
{% endblock %}
{% block content %}
<div class="clients-shell" x-data="projectsHub()" x-init="init()">
<header class="page-header herman-hero">
<div>
<h1>Projecten <span class="live-badge">Food Business</span></h1>
<p class="hero-sub">Gestructureerd per klant · NAS-mappen · foto's · documenten · packaging</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
<a class="btn btn-sm" href="/documents">Documenten →</a>
<a class="btn btn-sm" href="/packaging">Packaging →</a>
</div>
</header>
<div class="clients-kpi-row" style="grid-template-columns:repeat(4,1fr)">
<div class="clients-kpi"><span>Totaal projecten</span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span>Met klant</span><strong x-text="stats.with_client||0"></strong></div>
<div class="clients-kpi"><span>NAS mappen</span><strong x-text="stats.with_nas||0"></strong></div>
<div class="clients-kpi"><span>Assets gekoppeld</span><strong x-text="stats.assets||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">📋 Overzicht</button>
<button class="clients-tab" :class="{active: tab==='create'}" @click="tab='create'"> Nieuw project</button>
<button class="clients-tab" :class="{active: tab==='byclient'}" @click="tab='byclient'">👥 Per klant</button>
</div>
<div x-show="tab==='create'" x-cloak>
<section class="panel store-edit-panel">
<h2 style="margin-top:0">Nieuw food-business project</h2>
<p class="page-tip">Kies een type — per klant wordt automatisch een aparte NAS-map aangemaakt (niet alles op één hoop).</p>
<div class="project-type-grid">
<template x-for="t in types" :key="t.id">
<div class="project-type-card" :class="{selected: form.project_type===t.id}" @click="form.project_type=t.id">
<span class="icon" x-text="t.icon"></span>
<span class="label" x-text="t.label_nl"></span>
</div>
</template>
</div>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Projectnaam *</span>
<input class="form-input" x-model="form.name" placeholder="bv. Jumbo listing halal maaltijden Q3" /></label>
<label><span class="filter-label">Klant *</span>
<select class="form-input" x-model="form.client_id">
<option value="">— Selecteer klant —</option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
<label><span class="filter-label">Prioriteit</span>
<select class="form-input" x-model="form.priority">
<option value="low">Laag</option><option value="normal">Normaal</option>
<option value="high">Hoog</option><option value="urgent">Urgent</option>
</select></label>
<label class="full"><span class="filter-label">Beschrijving</span>
<textarea class="form-input" rows="3" x-model="form.description" placeholder="Doel, deliverables, deadline…"></textarea></label>
</div>
<button class="btn btn-primary" @click="create()" :disabled="!form.name.trim()">Project + NAS-map aanmaken</button>
</section>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek project…" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterType" @change="applyFilter()">
<option value="">Alle types</option>
<template x-for="t in types" :key="'f'+t.id"><option :value="t.id" x-text="t.label_nl"></option></template>
</select>
<select x-model="filterClient" @change="applyFilter()">
<option value="">Alle klanten</option>
<template x-for="c in clients" :key="'fc'+c.id"><option :value="c.id" x-text="c.name"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th>Naam</th><th>Type</th><th>Klant</th><th>Assets</th><th>NAS</th><th>Status</th><th></th>
</tr></thead>
<tbody>
<template x-for="p in filtered" :key="p.id">
<tr class="clickable-row" @click="open(p.id)">
<td><strong x-text="p.name"></strong></td>
<td x-text="typeLabel(p.project_type)"></td>
<td x-text="p.client_name||'—'"></td>
<td x-text="p.asset_count||0"></td>
<td><span class="clients-tag" x-show="p.nas_path">✓ map</span><span x-show="!p.nas_path"></span></td>
<td x-text="p.status"></td>
<td><button class="btn btn-sm" @click.stop="open(p.id)">Open</button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="tab==='byclient'" x-cloak>
<template x-for="c in clientsWithProjects" :key="c.id">
<section class="panel" style="margin-bottom:0.75rem">
<h3 x-text="c.name"></h3>
<p class="muted" style="font-size:0.75rem" x-show="c.nas_folder" x-text="'NAS: '+c.nas_folder"></p>
<template x-for="p in c.projects" :key="p.id">
<div class="feed-card clickable-row" @click="open(p.id)">
<strong x-text="p.name"></strong>
<small x-text="typeLabel(p.project_type)+' · '+(p.asset_count||0)+' assets'"></small>
</div>
</template>
<p x-show="!c.projects.length" class="muted">Geen projecten — maak er een aan voor deze klant.</p>
</section>
</template>
</div>
<aside class="clients-drawer" :class="{ open: drawer }" style="width:min(560px,96vw)">
<div class="clients-drawer-head">
<div>
<h2 x-text="selected?.name||''"></h2>
<p class="muted" x-text="typeLabel(selected?.project_type)+' · '+(selected?.client_name||'Geen klant')"></p>
</div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="selected">
<div>
<div class="clients-tabs" style="margin-bottom:0.75rem">
<button class="clients-tab" :class="{active: detailTab==='info'}" @click="detailTab='info'">Info</button>
<button class="clients-tab" :class="{active: detailTab==='assets'}" @click="detailTab='assets'">Assets</button>
<button class="clients-tab" :class="{active: detailTab==='photos'}" @click="detailTab='photos'; loadPhotos()">Foto's</button>
<button class="clients-tab" :class="{active: detailTab==='files'}" @click="detailTab='files'; loadNasFiles()">NAS files</button>
</div>
<div x-show="detailTab==='info'">
<p x-text="selected.description||'Geen beschrijving'"></p>
<div x-show="selected.nas_path">
<strong style="font-size:0.72rem;color:#94a3b8">NAS projectmap</strong>
<div class="nas-path-box" x-text="selected.nas_path"></div>
<p class="muted" style="font-size:0.7rem">Structuur: photos/ · documents/ · packaging/ · exports/ · briefs/</p>
</div>
<div x-show="selected.nas_client_root">
<strong style="font-size:0.72rem;color:#94a3b8">Klant NAS root</strong>
<div class="nas-path-box" x-text="selected.nas_client_root"></div>
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="ensureNas()" x-show="!selected.nas_path">NAS-map aanmaken</button>
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Packaging Studio</a>
<a class="btn btn-sm" href="/documents">Documenten hub →</a>
<a class="btn btn-sm" href="/clients">Klant CRM →</a>
</div>
</div>
<div x-show="detailTab==='assets'">
<div class="asset-grid">
<template x-for="a in (selected.assets||[])" :key="a.id">
<div class="asset-card">
<div class="type" x-text="a.asset_type"></div>
<strong x-text="a.title"></strong>
<small x-text="(a.source_agent||a.created_by||'')+' · '+(a.created_at||'').substring(0,10)"></small>
<div class="muted" style="font-size:0.68rem;word-break:break-all" x-show="a.file_path" x-text="a.file_path"></div>
<div class="btn-group" style="margin-top:0.45rem" x-show="a.asset_type==='packaging' && a.ref_id">
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Open in Studio</a>
<a class="btn btn-sm btn-ghost" :href="'/api/packaging/download/'+a.ref_id+'?format=pdf'" target="_blank">PDF</a>
</div>
</div>
</template>
</div>
<p x-show="!(selected.assets||[]).length" class="muted">Nog geen assets — koppel foto's of NAS-bestanden.</p>
</div>
<div x-show="detailTab==='photos'">
<p class="page-tip">Klik een foto om te koppelen aan dit project.</p>
<div class="photo-pick-grid">
<template x-for="ph in photos" :key="ph.id">
<div class="photo-pick-item" @click="linkPhoto(ph.id)">
<img :src="'/api/admin/photos/' + ph.id + '/image.jpg'" :alt="ph.filename" loading="lazy" />
</div>
</template>
</div>
<p x-show="!photos.length" class="muted">Geen foto's — gebruik Browser/Photos om te importeren.</p>
</div>
<div x-show="detailTab==='files'">
<p class="page-tip">Koppel een NAS-bestand aan dit project (gestructureerd in projectmap).</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek bestand…" x-model="fileQ" @input="filterNasFiles()" />
</div>
<template x-for="f in nasFilesFiltered.slice(0,40)" :key="f.storage_path||f.path">
<div class="feed-card clickable-row" @click="linkNasFile(f)">
<strong x-text="f.filename||f.name"></strong>
<small x-text="f.storage_path||f.path||''"></small>
</div>
</template>
<p x-show="!nasFiles.length" class="muted">Geen NAS-bestanden gevonden — check Documenten hub.</p>
</div>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function projectsHub() {
return {
tab: 'list', detailTab: 'info', busy: false, drawer: false,
stats: {}, projects: [], filtered: [], clients: [], types: [],
selected: null, photos: [], nasFiles: [], nasFilesFiltered: [], fileQ: '',
filterQ: '', filterType: '', filterClient: '',
form: { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' },
get clientsWithProjects() {
return this.clients.map(c => ({
...c,
projects: this.projects.filter(p => p.client_id === c.id),
})).filter(c => c.projects.length || true);
},
typeLabel(id) {
return (this.types.find(t => t.id === id) || {}).label_nl || id || '—';
},
async init() {
const q = new URLSearchParams(location.search).get('id');
await Promise.all([this.loadAll(), this.loadClients(), this.loadTypes()]);
if (q) this.open(parseInt(q, 10));
},
async loadTypes() {
const r = await fetch('/api/projects/types').then(x => x.json());
this.types = r.items || [];
},
async loadAll() {
this.busy = true;
try {
const [statsR, projR] = await Promise.all([
fetch('/api/projects/stats').then(x => x.json()),
fetch('/api/projects?limit=200').then(x => x.json()),
]);
this.stats = statsR.stats || {};
this.projects = projR.items || [];
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
async loadClients() {
try {
const r = await Cockpit.api('/clients');
this.clients = r.items || [];
} catch (e) { this.clients = []; }
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.projects.filter(p => {
if (this.filterType && p.project_type !== this.filterType) return false;
if (this.filterClient && String(p.client_id) !== String(this.filterClient)) return false;
if (!q) return true;
return [p.name, p.client_name, p.description].some(f => f && String(f).toLowerCase().includes(q));
});
},
async create() {
if (!this.form.name.trim()) return Cockpit.toast('Naam verplicht', 'error');
if (!this.form.client_id) return Cockpit.toast('Selecteer een klant voor NAS-structuur', 'warn');
await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.form.name,
client_id: parseInt(this.form.client_id, 10),
description: this.form.description,
project_type: this.form.project_type,
priority: this.form.priority,
}),
}).then(r => { if (!r.ok) throw new Error('Aanmaken mislukt'); return r.json(); });
this.form = { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' };
this.tab = 'list';
await this.loadAll();
Cockpit.toast('Project + NAS-map aangemaakt', 'success');
},
async open(id) {
const r = await fetch('/api/projects/' + id).then(x => x.json());
this.selected = r.project;
this.detailTab = 'info';
this.drawer = true;
},
async ensureNas() {
if (!this.selected) return;
const r = await fetch('/api/projects/' + this.selected.id + '/ensure-nas', { method: 'POST' }).then(x => x.json());
Cockpit.toast('NAS-map aangemaakt', 'success');
await this.open(this.selected.id);
},
async loadPhotos() {
try {
const r = await Cockpit.api('/photos?limit=60');
this.photos = r.items || r.photos || [];
} catch (e) { this.photos = []; }
},
async linkPhoto(photoId) {
if (!this.selected) return;
await Cockpit.api('/api/projects/photos/' + photoId + '/link', {
method: 'POST',
body: JSON.stringify({ project_id: this.selected.id }),
});
Cockpit.toast('Foto gekoppeld', 'success');
await this.open(this.selected.id);
await this.loadAll();
},
async loadNasFiles() {
try {
const r = await Cockpit.api('/documents/share-files?limit=200');
this.nasFiles = r.items || r.files || [];
this.filterNasFiles();
} catch (e) { this.nasFiles = []; this.nasFilesFiltered = []; }
},
filterNasFiles() {
const q = (this.fileQ || '').toLowerCase();
this.nasFilesFiltered = this.nasFiles.filter(f => {
const name = (f.filename || f.name || '').toLowerCase();
const path = (f.storage_path || f.path || '').toLowerCase();
return !q || name.includes(q) || path.includes(q);
});
},
async linkNasFile(f) {
if (!this.selected) return;
const path = f.storage_path || f.path;
const title = f.filename || f.name || path;
await fetch('/api/projects/' + this.selected.id + '/link-nas-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, file_path: path, asset_type: 'document' }),
});
Cockpit.toast('Bestand gekoppeld', 'success');
await this.open(this.selected.id);
},
};
}
</script>
{% endblock %}
+230 -20
View File
@@ -1,30 +1,240 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="suppliersCrud()">
<header class="page-header"><h1>Suppliers</h1><p class="subtitle page-tip">Vendor registry with ratings and lead times.</p></header>
<button class="btn btn-primary" @click="openNew()">Add supplier</button>
<table class="data-table"><thead><tr><th>Name</th><th>Country</th><th>Rating</th><th></th></tr></thead>
<tbody>{% for s in suppliers %}<tr class="clickable-row" @click="edit({{ s.id }}, '{{ s.name|e }}', '{{ (s.country or '')|e }}', {{ s.rating or 0 }})">
<td>{{ s.name }}</td><td>{{ s.country }}</td><td>{{ s.rating }}</td>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ s.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.country" /><input type="number" step="0.1" class="form-input" x-model="form.rating" />
<button class="btn btn-primary" @click="save()">Save</button></div></div>
<div class="clients-shell" x-data="suppliersHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('suppliers.title')">Leveranciers</h1>
<p class="subtitle page-tip" x-text="$t('suppliers.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<a class="btn btn-sm" href="/products" x-text="$t('nav.products')"></a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span x-text="$t('suppliers.total')"></span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.avg_rating')"></span><strong x-text="(stats.avg_rating||0)+'/5'"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.countries')"></span><strong x-text="stats.countries||0"></strong></div>
<div class="clients-kpi"><span x-text="$t('suppliers.avg_lead')"></span><strong x-text="(stats.avg_lead||0)+'d'"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('suppliers.tab_grid')"></button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('suppliers.tab_list')"></button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('suppliers.new')"></button>
</div>
</div>
<div x-show="tab==='grid'" x-cloak>
<div class="agents-neo-grid">
<template x-for="s in filtered" :key="s.id">
<article class="agent-soul-card" @click="openDetail(s)">
<strong x-text="s.name"></strong>
<div class="clients-card-meta" x-text="(s.country||'') + (s.category ? ' · '+categoryLabel(s.category) : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag mrr" x-show="s.rating" x-text="'★ '+(s.rating||0)"></span>
<span class="clients-tag" x-show="s.lead_time_days" x-text="s.lead_time_days+'d'"></span>
</div>
<div class="clients-card-meta" x-show="s.contact" x-text="s.contact"></div>
</article>
</template>
</div>
<p class="clients-empty" x-show="!filtered.length" x-text="$t('suppliers.no_suppliers')"></p>
</div>
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterCountry" @change="applyFilter()">
<option value="" x-text="$t('suppliers.filter_country') + ' — ' + $t('common.all')"></option>
<template x-for="c in countryOptions" :key="c"><option :value="c" x-text="c"></option></template>
</select>
<select x-model="filterCategory" @change="applyFilter()">
<option value="" x-text="$t('suppliers.filter_category') + ' — ' + $t('common.all')"></option>
<template x-for="cat in categories" :key="cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th x-text="$t('suppliers.name')"></th><th x-text="$t('suppliers.country')"></th>
<th x-text="$t('suppliers.category')"></th><th x-text="$t('suppliers.rating')"></th>
<th x-text="$t('suppliers.lead_time')"></th><th x-text="$t('suppliers.moq')"></th><th></th>
</tr></thead>
<tbody>
<template x-for="s in filtered" :key="'l'+s.id">
<tr class="clickable-row" @click="openDetail(s)">
<td><strong x-text="s.name"></strong></td>
<td x-text="s.country||'—'"></td>
<td x-text="categoryLabel(s.category)"></td>
<td x-text="s.rating!=null?s.rating:'—'"></td>
<td x-text="s.lead_time_days!=null?s.lead_time_days+'d':'—'"></td>
<td x-text="s.moq||'—'"></td>
<td>
<button class="btn btn-sm" @click.stop="editSupplier(s)" x-text="$t('common.edit')"></button>
<button class="btn btn-sm btn-reject" @click.stop="remove(s.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="form.id ? $t('common.edit') : $t('suppliers.new')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label" x-text="$t('suppliers.name')"></span>
<input class="form-input" x-model="form.name" /></label>
<label><span class="filter-label" x-text="$t('suppliers.country')"></span>
<input class="form-input" x-model="form.country" list="country-list" /></label>
<datalist id="country-list"><option>NL</option><option>BE</option><option>DE</option><option>TR</option><option>MA</option></datalist>
<label><span class="filter-label" x-text="$t('suppliers.category')"></span>
<select class="form-input" x-model="form.category">
<option value="" x-text="$t('common.none')"></option>
<template x-for="cat in categories" :key="'fc'+cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
</select></label>
<label><span class="filter-label" x-text="$t('suppliers.rating')"></span>
<input type="number" class="form-input" x-model="form.rating" min="0" max="5" step="0.1" /></label>
<label><span class="filter-label" x-text="$t('suppliers.moq')"></span>
<input type="number" class="form-input" x-model="form.moq" /></label>
<label><span class="filter-label" x-text="$t('suppliers.lead_time')"></span>
<input type="number" class="form-input" x-model="form.lead_time_days" /></label>
<label class="full"><span class="filter-label" x-text="$t('suppliers.contact')"></span>
<textarea class="form-input" rows="3" x-model="form.contact" placeholder="naam@bedrijf.nl · +31…"></textarea></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
<button class="btn btn-reject" x-show="form.id" @click="remove(form.id); modal=false" x-text="$t('common.delete')"></button>
</div>
</div>
</div>
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div><h2 x-text="detail?.supplier?.name||''"></h2>
<p class="muted" x-text="(detail?.supplier?.country||'') + ' · ' + categoryLabel(detail?.supplier?.category)"></p></div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.supplier">
<div class="clients-card-meta">
<div x-show="detail.supplier.rating"><strong x-text="$t('suppliers.rating')"></strong>: <span x-text="detail.supplier.rating+'/5'"></span></div>
<div x-show="detail.supplier.moq"><strong x-text="$t('suppliers.moq')"></strong>: <span x-text="detail.supplier.moq"></span></div>
<div x-show="detail.supplier.lead_time_days"><strong x-text="$t('suppliers.lead_time')"></strong>: <span x-text="detail.supplier.lead_time_days+' dagen'"></span></div>
<div x-show="detail.supplier.contact" style="white-space:pre-wrap;margin-top:0.5rem" x-text="detail.supplier.contact"></div>
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editSupplier(detail.supplier)" x-text="$t('common.edit')"></button>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function suppliersCrud() {
return { show: false, id: null, form: { name: '', country: '', rating: 4, category: '', moq: null, lead_time_days: null, contact: '' },
openNew() { this.id=null; this.form={ name:'', country:'', rating:4, category:'', moq:null, lead_time_days:null, contact:'' }; this.show=true; },
edit(id,name,country,rating) { this.id=id; this.form={name,country,rating,category:'',moq:null,lead_time_days:null,contact:''}; this.show=true; },
async save() {
const body = { ...this.form, rating: parseFloat(this.form.rating) };
if (this.id) await Cockpit.api('/suppliers/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
location.reload();
function suppliersHub() {
return {
tab: 'grid', busy: false, modal: false, drawer: false,
stats: {}, suppliers: [], filtered: [], detail: null,
filterQ: '', filterCountry: '', filterCategory: '',
form: {}, categories: [], countryOptions: [],
buildCategories() {
this.categories = [
{ id: 'Meat', label: this.$t('suppliers.cat_meat') },
{ id: 'Dairy', label: this.$t('suppliers.cat_dairy') },
{ id: 'Spices', label: this.$t('suppliers.cat_spices') },
{ id: 'Packaging', label: this.$t('suppliers.cat_packaging') },
{ id: 'Logistics', label: this.$t('suppliers.cat_logistics') },
{ id: 'Ingredients', label: this.$t('suppliers.cat_ingredients') },
{ id: 'Other', label: this.$t('suppliers.cat_other') },
];
},
categoryLabel(id) {
if (!id) return '—';
return (this.categories.find(c => c.id === id) || {}).label || id;
},
async init() {
this.buildCategories();
document.addEventListener('foodlinkk:locale', () => this.buildCategories());
await this.loadAll();
},
async loadAll() {
this.busy = true;
try {
const [statsR, supR] = await Promise.all([
Cockpit.api('/suppliers/stats'),
Cockpit.api('/suppliers'),
]);
this.stats = statsR.stats || {};
this.suppliers = supR.items || [];
const countries = new Set();
this.suppliers.forEach(s => { if (s.country) countries.add(s.country); });
this.countryOptions = Array.from(countries).sort();
this.applyFilter();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.suppliers.filter(s => {
if (this.filterCountry && s.country !== this.filterCountry) return false;
if (this.filterCategory && s.category !== this.filterCategory) return false;
if (!q) return true;
return [s.name, s.country, s.category, s.contact].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openModal() {
this.form = { id: null, name: '', country: 'NL', category: '', rating: 4, moq: '', lead_time_days: '', contact: '' };
this.modal = true;
},
editSupplier(s) {
this.form = {
id: s.id, name: s.name || '', country: s.country || '', category: s.category || '',
rating: s.rating ?? 4, moq: s.moq ?? '', lead_time_days: s.lead_time_days ?? '', contact: s.contact || '',
};
this.modal = true; this.drawer = false;
},
async save() {
const body = {
name: this.form.name.trim(),
country: this.form.country || null,
category: this.form.category || null,
rating: this.form.rating !== '' ? parseFloat(this.form.rating) : null,
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
lead_time_days: this.form.lead_time_days !== '' ? parseInt(this.form.lead_time_days, 10) : null,
contact: this.form.contact || null,
};
try {
if (this.form.id) await Cockpit.api('/suppliers/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
Cockpit.toast(window.I18n.t('common.saved'), 'success');
this.modal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!Cockpit.confirmDelete()) return;
await Cockpit.api('/suppliers/' + id, { method: 'DELETE' });
await this.loadAll();
},
async openDetail(s) {
const d = await Cockpit.api('/suppliers/' + s.id + '/detail');
this.detail = d;
this.drawer = true;
},
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/suppliers/' + id, { method: 'DELETE' }); location.reload(); }
};
}
</script>