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
+8
View File
@@ -24,6 +24,8 @@ docker compose up -d
## Documentatie ## Documentatie
- **[Platform AZ](docs/PLATFORM-AZ.md)** — volledige referentie (start hier)
- **[Context voor Cursor](docs/CONTEXT.md)** — korte import op andere laptop
- [Architectuur](docs/ARCHITECTURE.md) - [Architectuur](docs/ARCHITECTURE.md)
- [Deploy](docs/DEPLOY.md) - [Deploy](docs/DEPLOY.md)
- [Apps & pagina's](docs/APPS.md) - [Apps & pagina's](docs/APPS.md)
@@ -31,6 +33,12 @@ docker compose up -d
- [Agents](docs/AGENTS.md) - [Agents](docs/AGENTS.md)
- [CSS thema](docs/CSS-THEME.md) - [CSS thema](docs/CSS-THEME.md)
## Gitea & SysOps
- Repository: http://10.4.7.18:3001/aissa/foodlinkk-command-center
- **SysOps** pusht bij elke `deploy-all.sh` de volledige codebase naar `main`
- SysOps-verslag: IT Ops tab + Herman dagbriefing
## Belangrijkste routes ## Belangrijkste routes
- `/` — Herman dashboard - `/` — Herman dashboard
+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.settings_api import settings_router
from app.routes.agents_api import router as agents_api_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.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 BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@@ -74,6 +75,7 @@ for r in (
settings_router, settings_router,
agents_api_router, agents_api_router,
marketing_api_router, marketing_api_router,
projects_api_router,
): ):
app.include_router(r) app.include_router(r)
+141 -1
View File
@@ -168,7 +168,15 @@ class DelegateBody(BaseModel):
@admin_router.get("/clients") @admin_router.get("/clients")
def list_clients(): def list_clients():
return {"items": _serialize_rows(fetch_all( 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} 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 --- # --- Deals CRUD ---
@admin_router.get("/deals") @admin_router.get("/deals")
@@ -239,6 +320,36 @@ def delete_deal(deal_id: int):
# --- Products CRUD --- # --- 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") @admin_router.get("/products")
def list_products(): def list_products():
return {"items": _serialize_rows(fetch_all( return {"items": _serialize_rows(fetch_all(
@@ -284,6 +395,35 @@ def margin_calc(body: MarginBody):
# --- Suppliers CRUD --- # --- 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") @admin_router.get("/suppliers")
def list_suppliers(): def list_suppliers():
return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))} 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")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AGENT_STATUSES = [ AGENT_STATUSES = [
{"name": "Herman", "role": "CEO Briefing", "color": "cyan"}, {"name": "Herman", "role": "Co-CEO · Takenverdeler", "color": "cyan"},
{"name": "Sales", "role": "Pipeline", "color": "purple"}, {"name": "Sales", "role": "Pipeline", "color": "purple"},
{"name": "Marketing", "role": "Social & Content", "color": "amber"}, {"name": "Marketing", "role": "Social & Content", "color": "amber"},
{"name": "Ops", "role": "Operations", "color": "green"}, {"name": "Ops", "role": "Operations", "color": "green"},
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("") @router.get("")
async def agents_page(request: Request): async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls") 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" active_tab = "souls"
events: list = [] events: list = []
try: try:
+133 -2
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.db import fetch_all 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"]) router = APIRouter(prefix="/api/agents", tags=["agents-api"])
@@ -20,6 +20,21 @@ class SoulUpdate(BaseModel):
is_active: Optional[bool] = None 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") @router.get("/souls")
def api_list_souls() -> dict[str, Any]: def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())} 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} 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}") @router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]: def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try: 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] 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 image_url: str | None = None
media_ids: list[int] = Field(default_factory=list) media_ids: list[int] = Field(default_factory=list)
channels: list[str] = Field(default_factory=list) channels: list[str] = Field(default_factory=list)
project_id: int | None = None
@router.post("/upload") @router.post("/upload")
@@ -91,7 +92,9 @@ def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks)
), ),
) )
job_id = int(row["id"]) 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} return {"job_id": job_id, "status": "queued", "channels": channels}
+201 -40
View File
@@ -1,18 +1,21 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Optional
import httpx import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response from fastapi.responses import Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import settings from app.config import settings
from app.services import packaging_nas, projects
router = APIRouter(tags=["packaging"]) router = APIRouter(tags=["packaging"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
TOOLS = settings.TOOLS_API_URL.rstrip("/")
class PackagingGenerateBody(BaseModel): class PackagingGenerateBody(BaseModel):
@@ -20,68 +23,226 @@ class PackagingGenerateBody(BaseModel):
width_mm: float = Field(default=120, gt=0) width_mm: float = Field(default=120, gt=0)
height_mm: float = Field(default=80, gt=0) height_mm: float = Field(default=80, gt=0)
depth_mm: float = Field(default=40, ge=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) elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = 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") @router.get("/packaging")
async def packaging_page(request: Request): async def packaging_page(request: Request, project_id: Optional[int] = None):
return templates.TemplateResponse( return templates.TemplateResponse(
"packaging.html", "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") @router.post("/api/packaging/generate")
async def proxy_packaging_generate(body: PackagingGenerateBody): async def proxy_packaging_generate(body: PackagingGenerateBody):
try: try:
async with httpx.AsyncClient(timeout=60.0) as client: body = await _ensure_project(body)
r = await client.post( r = await _tools_request("POST", "/packaging/generate", json=body.model_dump())
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate", r.raise_for_status()
json=body.model_dump(), result = r.json()
) return await _post_nas_export(result)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc) raise _tools_error(exc) from exc
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/projects") @router.get("/api/packaging/projects")
async def proxy_packaging_projects(limit: int = 30): async def proxy_packaging_projects(limit: int = 30):
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: r = await _tools_request("GET", "/packaging/projects", params={"limit": limit})
r = await client.get( r.raise_for_status()
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/projects", return r.json()
params={"limit": limit},
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc) raise _tools_error(exc) from exc
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/download/{project_id}") @router.get("/api/packaging/download/{project_id}")
async def proxy_packaging_download(project_id: str, format: str = "svg"): async def proxy_packaging_download(project_id: str, format: str = "svg"):
try: try:
async with httpx.AsyncClient(timeout=120.0) as client: r = await _tools_request("GET", f"/packaging/download/{project_id}", params={"format": format})
r = await client.get( r.raise_for_status()
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{project_id}", media = r.headers.get("content-type", "application/octet-stream")
params={"format": format}, disposition = r.headers.get("content-disposition")
) headers = {"content-disposition": disposition} if disposition else {}
r.raise_for_status() return Response(content=r.content, media_type=media, headers=headers)
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: except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc) raise _tools_error(exc) from exc
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc @router.get("/api/packaging/{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 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): class NoteBody(BaseModel):
body: str body: str
title: Optional[str] = None title: Optional[str] = None
@@ -88,6 +130,13 @@ async def _tools_delete(path: str) -> Any:
return resp.json() 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") @router.get("/retail")
async def retail_page(request: Request): async def retail_page(request: Request):
stats = await _tools_get("/retail/stats") 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())) 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") @router.post("/api/retail/enrich")
async def api_retail_enrich(limit: int = Query(100, ge=1, le=200)): async def api_retail_enrich(limit: int = Query(100, ge=1, le=200)):
return JSONResponse(await _tools_post("/retail/enrich", params={"limit": limit})) 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( rows = fetch_all(
"""SELECT s.*, """SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count, (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""" FROM agent_souls s ORDER BY s.display_name"""
) )
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -25,13 +31,27 @@ def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
) )
if not row: if not row:
return None 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 = 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 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["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'") 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: try:
data["deals_by_stage"] = fetch_all( 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" "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: except Exception:
data["regulation_highlights"] = [] 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: try:
data["food_market_highlights"] = fetch_all( 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 """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 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') WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR i.title ILIKE ANY (ARRAY['%supermarkt%','%retail%','%jumbo%','%ahold%','%halal%','%maaltijd%']) OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST LIMIT 10""" ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
) )
except Exception: except Exception:
data["food_market_highlights"] = [] data["food_market_highlights"] = []
data["activity_log"] = _build_activity_log(data)
return 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: def build_template_report(data: dict[str, Any]) -> str:
lines = [ lines = [
f"# Foodlinkk Dagrapport — {data['date']}", 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] ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})") lines.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"): if data.get("pending_items"):
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"]) lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]: for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}") 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 []: 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" 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 = ( prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n" "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" "## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 concrete bullets met CRM/retail acties)\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: groei supermarkt partnerships, halal markt, milestones komende weken)\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"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\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.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"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n" f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
) )
system = ( system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf warm, professioneel en actionable. " "Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Focus op halal kant-en-klaar retail groei in Nederland. Geen vage tekst — concrete namen en acties." "Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
) )
try: try:
return await ollama.generate(prompt, system=system, timeout=120.0) 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"]) lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [ 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", "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", "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: if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**") actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]: for a in actions[:6]:
@@ -354,6 +518,18 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
) )
except Exception: except Exception:
pass 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: try:
execute( execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata) """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", "herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000], 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: except Exception:
+68
View File
@@ -5,6 +5,7 @@ import httpx
from app.config import settings from app.config import settings
from app.db import execute, fetch_one from app.db import execute, fetch_one
from app.services import ollama from app.services import ollama
from app.services import packaging_agent
AGENTS: dict[str, dict[str, str]] = { AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."}, "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."}, "product": {"name": "Product", "persona": "SKU development, launch timelines, shelf readiness."},
"halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."}, "halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."},
"design": {"name": "Design", "persona": "Packaging, visual identity, retail presentation."}, "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."}, "knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
} }
@@ -82,6 +84,72 @@ def _pick_agent(raw: str) -> str:
return "knowledge" return "knowledge"
async def chat(message: str) -> dict[str, Any]: 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): if _wants_image(message):
prompt = _extract_image_prompt(message) prompt = _extract_image_prompt(message)
try: 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"} 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() started_at = datetime.utcnow()
execute( execute(
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s", "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", title=f"Social publish job #{job_id} afgerond",
body=f"Published={published}, skipped={skipped}, failed={failed}", body=f"Published={published}, skipped={skipped}, failed={failed}",
status=final_status, 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)); 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 { .mesh-herman circle.ring {
fill: none; fill: none;
stroke: rgba(255, 215, 0, 0.35); stroke: rgba(255, 215, 0, 0.35);
@@ -80,3 +97,26 @@
color: #94a3b8; color: #94a3b8;
font-size: 0.82rem; 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; } } @keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.hm-chart-head small { color: #64748b; font-size: 0.7rem; } .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 { .briefing-loading-overlay {
position: absolute; inset: 0; background: rgba(10, 14, 20, 0.8); 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-stock-chain { font-size: 0.65rem; color: #64748b; margin-bottom: 0.35rem; }
.hm-spark { width: 100%; height: 36px; display: block; opacity: 0.85; } .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-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-scroll { max-height: 220px; overflow-y: auto; }
.hm-highlight-item { .hm-highlight-item {
+126 -73
View File
@@ -9,122 +9,175 @@
align-items: center; align-items: center;
gap: 0.8rem; gap: 0.8rem;
flex-wrap: wrap; flex-wrap: wrap;
padding: 1.25rem 1.5rem;
margin-bottom: 0;
} }
.ops-header h1 { .ops-header h1 { margin: 0; font-size: 1.55rem; }
margin: 0;
}
.ops-subtitle {
color: #93a4b8;
margin: 0.25rem 0 0;
}
.ops-kpis {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.ops-kpi {
border: 1px solid rgba(120, 170, 255, 0.25);
border-radius: 12px;
padding: 0.8rem;
background: linear-gradient(150deg, rgba(15, 23, 42, 0.95), rgba(17, 30, 53, 0.7));
box-shadow: inset 0 0 20px rgba(36, 99, 235, 0.08), 0 0 20px rgba(30, 64, 175, 0.12);
}
.ops-kpi .label {
color: #8ba3c4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.ops-kpi .value {
font-size: 1.3rem;
font-weight: 700;
margin-top: 0.2rem;
}
.topology-card { .topology-card {
position: relative; position: relative;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(56, 189, 248, 0.3); border: 1px solid rgba(0, 229, 255, 0.25);
background: radial-gradient(circle at top, rgba(21, 43, 77, 0.35), rgba(8, 15, 29, 0.92)); background: radial-gradient(circle at 50% 20%, rgba(0, 229, 255, 0.08), rgba(8, 15, 29, 0.95));
overflow: hidden; overflow: hidden;
padding: 0.5rem;
} }
.topology-canvas { .topology-canvas {
width: 100%; width: 100%;
min-height: 520px; min-height: 560px;
display: block;
} }
.topology-lines line { .ops-layer-line {
stroke: rgba(59, 130, 246, 0.42); 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; stroke-width: 2;
} }
.pulse-line { .pulse-line {
stroke: rgba(56, 189, 248, 0.95); stroke: rgba(0, 229, 255, 0.95);
stroke-width: 2.6; stroke-width: 2.5;
stroke-dasharray: 10 14; stroke-dasharray: 8 16;
animation: ops-line-pulse 1.8s linear infinite; animation: ops-line-pulse 1.6s linear infinite;
filter: drop-shadow(0 0 6px rgba(56, 189, 248, 0.85)); filter: drop-shadow(0 0 6px rgba(0, 229, 255, 0.8));
} }
.ops-node circle { .ops-node-circle {
fill: #0f172a; fill: #0f172a;
stroke-width: 2.2; stroke-width: 2.5;
} }
.ops-node text { .ops-node-title {
fill: #d9e8ff; fill: #e2e8f0;
font-size: 12px; 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; font-weight: 600;
} }
.ops-node .sub { .ops-node-role {
fill: #8ea7ca; fill: #64748b;
font-size: 11px; font-size: 8px;
font-weight: 500; text-anchor: middle;
} }
.node-proxmox circle { .node-proxmox .ops-node-circle {
stroke: #f59e0b; 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; 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; stroke: #22c55e;
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.45));
} }
.status-offline circle { .status-online .ops-node-circle { stroke: #22c55e; }
stroke: #ef4444; .status-offline .ops-node-circle { stroke: #ef4444; filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.5)); }
filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.6)); .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, .ops-monitor.status-healthy .ops-monitor-box { stroke: #22c55e; }
.status-unknown circle { .ops-monitor.status-warn .ops-monitor-box { stroke: #f59e0b; }
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 { .ops-meta {
color: #8ba3c4; color: #8ba3c4;
font-size: 0.85rem; font-size: 0.85rem;
} }
@keyframes ops-line-pulse { .ops-panels-grid {
from { display: grid;
stroke-dashoffset: 26; grid-template-columns: 1fr 1fr;
} gap: 1rem;
to { }
stroke-dashoffset: 0; @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); while (el.firstChild) el.removeChild(el.firstChild);
} }
function curvePath(x1, y1, x2, y2) { function linePath(x1, y1, x2, y2) {
const cx1 = x1 + (x2 - x1) * 0.35; const midY = y1 + (y2 - y1) * 0.45;
const cy1 = y1; return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const cx2 = x1 + (x2 - x1) * 0.72;
const cy2 = y2;
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
} }
async function loadMeshData() { async function loadMeshData() {
@@ -28,63 +25,103 @@
function drawMesh(svg, data) { function drawMesh(svg, data) {
clear(svg); clear(svg);
const vb = svg.viewBox.baseVal; const vb = svg.viewBox.baseVal;
const width = vb && vb.width ? vb.width : 1000; const width = vb && vb.width ? vb.width : 1100;
const height = vb && vb.height ? vb.height : 620; const height = vb && vb.height ? vb.height : 640;
const cx = width / 2;
const cy = height / 2; const cx = width / 2;
const radius = Math.min(width, height) * 0.35; 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 = {}; const edgesBySource = {};
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; }); (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) => { souls.forEach((node, i) => {
const a = (Math.PI * 2 * i) / Math.max(1, souls.length) - Math.PI / 2; node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
node._x = cx + Math.cos(a) * radius; if (agentCount === 1) node._x = cx;
node._y = cy + Math.sin(a) * radius; 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 edgesLayer = make('g');
const nodesLayer = make('g'); const nodesLayer = make('g');
svg.appendChild(edgesLayer); svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer); svg.appendChild(nodesLayer);
souls.forEach((node) => { function addEdge(x1, y1, x2, y2, pulse) {
const pathDef = curvePath(node._x, node._y, cx, cy); const d = linePath(x1, y1, x2, y2);
const edge = make('path', { d: pathDef, class: 'mesh-edge' }); edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
edgesLayer.appendChild(edge); if (pulse) {
const p = make('path', { d, class: 'mesh-pulse' });
if (edgesBySource[node.agent_key]) { p.style.animationDuration = pulse + 's';
const pulse = make('path', { d: pathDef, class: 'mesh-pulse' }); edgesLayer.appendChild(p);
pulse.style.animationDuration = `${Math.max(1.2, 3.5 - Math.min(2.2, edgesBySource[node.agent_key].weight / 8))}s`;
edgesLayer.appendChild(pulse);
} }
}
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' }); const herman = make('g', { class: 'mesh-node mesh-herman' });
herman.appendChild(make('circle', { class: 'ring', cx, cy, r: 62 })); herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
herman.appendChild(make('circle', { class: 'main', cx, cy, r: 46 })); herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
const crown = make('text', { x: cx, y: cy - 2, 'font-size': 24, 'text-anchor': 'middle' }); const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
crown.textContent = '👑'; crown.textContent = '👑';
herman.appendChild(crown); herman.appendChild(crown);
const label = make('text', { x: cx, y: cy + 22 }); const hLabel = make('text', { x: cx, y: hermanY + 18 });
label.textContent = 'Herman · Co-CEO'; hLabel.textContent = 'Herman · Co-CEO';
herman.appendChild(label); 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); nodesLayer.appendChild(herman);
souls.forEach((node) => { souls.forEach((node) => {
const group = make('g', { class: `mesh-node ${node.health || 'idle'}` }); const key = (node.agent_key || '').toLowerCase();
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 24 })); 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 shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
const t = make('text', { x: node._x, y: node._y + 4 }); const t = make('text', { x: node._x, y: node._y + 4 });
t.textContent = shortName; t.textContent = shortName;
group.appendChild(t); group.appendChild(t);
const role = make('text', { x: node._x, y: node._y + 42, 'font-size': 10, opacity: 0.82 }); const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
role.textContent = node.role_title || node.agent_key; role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
group.appendChild(role); 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', () => { group.addEventListener('click', () => {
if (window.Cockpit && Cockpit.toast) { 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); nodesLayer.appendChild(group);
+31 -13
View File
@@ -100,40 +100,58 @@ window.AnalyticsCharts = (function () {
} }
} }
function render(data) { function render(data, mode) {
if (!data) return; if (!data) return;
mode = mode || window.__vizMode || 'neo-bars';
window.__analyticsLastData = data;
renderKpis(document.getElementById('analytics-kpis'), data.kpis); 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'); (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'); (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'); (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; }), if (useRing) {
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); })); 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; }), doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); })); (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); (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); }), if (mode === 'neo-table') {
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); })); 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'; }), 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); })); (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'); (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'; }), 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); })); (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); (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); (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'); (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; }), 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); })); (data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
renderTables(data); renderTables(data);
} }
document.addEventListener('foodlinkk:vizmode', function (ev) {
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
});
return { render: render, destroyAll: destroyAll }; 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: '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: '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: '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 }, { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
]; ];
container.className = 'hm-neo-kpi-row'; container.className = 'hm-neo-kpi-row';
@@ -257,11 +257,16 @@ window.BriefingCharts = (function () {
function renderRetail(container, stats) { function renderRetail(container, stats) {
if (!container) return; if (!container) return;
var opps = stats.top_opportunities || []; var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
if (!opps.length) { container.innerHTML = '<p class="empty-state">Geen kansen — <a href="/retail">open Retail 360</a></p>'; return; } if (!items.length) {
container.innerHTML = opps.map(function (o) { container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
var score = Math.round(Number(o.halal_opportunity_score) || 0); return;
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>'; }
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(''); }).join('');
} }
@@ -289,16 +294,17 @@ window.BriefingCharts = (function () {
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' }, { icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' }, { 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 ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) { 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>' + 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>'; '<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>'; }).join('') + '</div>';
if (opps.length) { if (trending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Top halal kansen</h4><ul class="hm-exec-list">'; 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">';
opps.forEach(function (o) { trending.forEach(function (r) {
html += '<li><a href="/retail">' + (o.chain || '') + ' · ' + (o.name || '') + ' (' + (o.city || '') + ') — ' + Math.round(Number(o.halal_opportunity_score || 0)) + '/100</a></li>'; html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
}); });
html += '</ul>'; html += '</ul>';
} }
@@ -309,7 +315,19 @@ window.BriefingCharts = (function () {
}); });
html += '</ul>'; 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; 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>'; 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; if (!root || !stats) return;
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
renderKpis(document.getElementById('briefing-kpis'), stats); renderKpis(document.getElementById('briefing-kpis'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats); renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats); renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats); renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), 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); renderPipeline(document.getElementById('chart-pipeline'), stats);
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderWords(document.getElementById('chart-words'), 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) { function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats); renderLive(root, stats, vizMode);
renderText(root, content, createdAt); renderText(root, content, createdAt);
} }
+3 -1
View File
@@ -40,7 +40,9 @@ window.Cockpit = (function () {
} }
function confirmDelete(message) { 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) { 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" %} {% extends "base.html" %}
{% block extra_head %} {% 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/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/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 %} {% endblock %}
{% block content %} {% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()"> <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> <p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header> </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"> <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'"> <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)"> <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)"> <button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span> <span class="vtab-icon">🕸️</span><span>Netwerk</span>
</button> </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> </nav>
<div class="vtabs-content"> <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:#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:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div> <div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner"></div></div><div><div class="hm-neo-kpi-label">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>
<div class="agents-neo-grid"> <div class="agents-neo-grid">
<template x-for="s in souls" :key="s.agent_key"> <template x-for="s in souls" :key="s.agent_key">
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)"> <article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
<div class="hm-neo-kpi-top"> <div class="agent-char-wrap" :class="s.agent_key"
<div class="agent-avatar-wrap"> :data-agent-avatar="s.agent_key"
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')"> :data-agent-color="s.avatar_color || '#1e3a5f'"
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]"> x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<span class="hair" x-show="hasHair(s.agent_key)"></span> <strong x-text="s.display_name"></strong>
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span> <div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="eye left"></span> <span class="agent-status-pill" :class="statusClass(s)">
<span class="eye right"></span> <span class="dot"></span>
<span class="mouth"></span> <span x-text="statusLabel(s)"></span>
</div> </span>
</div> <p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
<span class="agent-status-dot" x-show="s.is_active !== false"></span> <p class="agent-role-desc" x-text="(s.responsibilities || '').substring(0,120)"></p>
</div>
<div style="flex:1">
<strong x-text="s.display_name"></strong>
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
<span class="agent-tag" x-text="s.agent_key"></span>
</div>
</div>
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
<div class="agent-meta-row"> <div class="agent-meta-row">
<small x-text="(s.event_count||0) + ' events'"></small> <button type="button" class="agent-events-link"
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small> @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>
<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> </article>
</template> </template>
</div> </div>
<div class="agent-soul-editor" x-show="selected" x-cloak> <div class="agent-soul-editor" x-show="selected" x-cloak>
<div class="agent-editor-header"> <div class="agent-editor-header">
<div class="agent-avatar-wrap"> <div class="agent-char-wrap large" :class="selected?.agent_key"
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')"> :data-agent-avatar="selected?.agent_key"
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]"> :data-agent-color="selected?.avatar_color || '#1e3a5f'"
<span class="hair" x-show="hasHair(selected?.agent_key)"></span> x-effect="selected && $nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, selected.agent_key, selected.avatar_color))"></div>
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
<span class="eye left"></span>
<span class="eye right"></span>
<span class="mouth"></span>
</div>
</div>
</div>
<div style="flex:1"> <div style="flex:1">
<h2><span x-text="selected?.display_name"></span><span x-text="selected?.role_title"></span></h2> <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> <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>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>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> <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"> <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> </template>
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p> <p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
</div> </div>
@@ -98,7 +108,7 @@
<section x-show="tab==='mesh'" x-cloak> <section x-show="tab==='mesh'" x-cloak>
<div class="panel"> <div class="panel">
<h2 style="margin-top:0;">Netwerk</h2> <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"> <div class="mesh-wrap">
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg> <svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
</div> </div>
@@ -110,11 +120,82 @@
</div> </div>
</div> </div>
</section> </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> </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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% 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 src="/static/js/agents-mesh.js"></script>
<script> <script>
function agentsSoulHub() { function agentsSoulHub() {
@@ -123,22 +204,67 @@ function agentsSoulHub() {
tabIndicatorTop: 0, tabIndicatorTop: 0,
tabIndicatorH: 40, tabIndicatorH: 40,
meshLoaded: false, meshLoaded: false,
pendingCount: 0,
souls: [], selected: null, form: {}, saving: false, 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 totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; }, get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
avatarStyle(key) { statusClass(s) {
const m = { const st = (s.current_status || '').toLowerCase();
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob', if (st === 'needs_approval' || st === 'pending') return 'busy';
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl', if (st === 'error' || st === 'rejected') return 'error';
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short', if (st === 'completed' || (s.events_6h > 0)) return 'active';
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses', return 'idle';
}; },
return m[key] || 'style-male-short'; 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() { async init() {
await this.load(); await this.load();
await this.loadPendingCount();
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
this.$nextTick(() => { this.$nextTick(() => {
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active'); const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
if (activeBtn) this.updateTabIndicator(activeBtn); if (activeBtn) this.updateTabIndicator(activeBtn);
@@ -159,6 +285,13 @@ function agentsSoulHub() {
url.searchParams.set('tab', next); url.searchParams.set('tab', next);
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh(); 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() { mountMesh() {
if (this.meshLoaded || !window.AgentsMesh) return; if (this.meshLoaded || !window.AgentsMesh) return;
@@ -169,6 +302,22 @@ function agentsSoulHub() {
const r = await fetch('/api/agents/souls').then(x => x.json()); const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || []; this.souls = r.items || [];
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key); 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) { async select(key) {
const r = await fetch('/api/agents/souls/' + key).then(x => x.json()); 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> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -71,5 +71,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script> <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 %} {% 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/topnav-neo.css?v=3" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" /> <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/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://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> <script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
@@ -30,46 +31,48 @@
</div> </div>
<div class="app-sidebar-group"> <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>
<div class="app-sidebar-group"> <div class="app-sidebar-group">
<div class="app-sidebar-label">CRM</div> <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 %}">Clients</a> <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 %}">Deals</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 %}">Products</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 %}">Suppliers</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>
<div class="app-sidebar-group"> <div class="app-sidebar-group">
<div class="app-sidebar-label">Intel</div> <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 %}">Beurs Live</a> <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 %}">Retail</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 %}">Browser</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 %}">Documents</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>
<div class="app-sidebar-group"> <div class="app-sidebar-group">
<div class="app-sidebar-label">Hermes</div> <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 %}">Telegram</a> <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>
<div class="app-sidebar-group"> <div class="app-sidebar-group">
<div class="app-sidebar-label">Agents</div> <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 %}">Agents</a> <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 %}">Marketing</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 %}">Chat</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 %}">Studio</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 %}">Packaging</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 %}">Reports</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>
<div class="app-sidebar-group"> <div class="app-sidebar-group">
<div class="app-sidebar-label">System</div> <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 %}">Analytics</a> <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 %}">Voice</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 %}">IT Ops</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 %}">Settings</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> </div>
</aside> </aside>
@@ -79,6 +82,11 @@
<header class="app-topbar"> <header class="app-topbar">
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen"></button> <button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen"></button>
<span class="page-tip">Foodlinkk</span> <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> <div class="app-clock"><strong x-text="clockDate"></strong> · <span x-text="clockTime"></span></div>
</header> </header>
<main class="main-topnav">{% block content %}{% endblock %}</main> <main class="main-topnav">{% block content %}{% endblock %}</main>
@@ -91,11 +99,15 @@
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button> <button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div> </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 src="/static/js/live-pulse.js?v=2"></script>
<script> <script>
function appShell() { function appShell() {
return { return {
locale: 'nl',
drawerOpen: false, drawerOpen: false,
navOpen: false, navOpen: false,
showInstall: false, showInstall: false,
@@ -103,10 +115,16 @@
clockDate: '—', clockDate: '—',
clockTime: '—', clockTime: '—',
_offset: 0, _offset: 0,
initApp() { async initApp() {
if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl';
this.initClock(); this.initClock();
this.initPwa(); this.initPwa();
}, },
async setLocale(loc) {
if (window.I18n) await window.I18n.setLocale(loc);
this.locale = loc;
},
async initClock() { async initClock() {
try { try {
const r = await fetch('/api/server-time').then(x => x.json()); const r = await fetch('/api/server-time').then(x => x.json());
+527 -42
View File
@@ -1,54 +1,539 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %} {% block content %}
<div x-data="clientsCrud()"> <div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header"><h1>Clients</h1> <header class="page-header">
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header> <div>
<button class="btn btn-primary" @click="openModal()">Add client</button> <h1 x-text="$t('clients.title')">CRM · Klanten</h1>
<div class="kanban"> <p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
{% set stages = ['intake','discovery','proposal','active','churned'] %} </div>
{% for st in stages %} <div class="retail-actions">
<div class="kanban-col"><h3>{{ st }}</h3> <button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
{% for c in clients if c.stage == st %} <a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')"> <a class="btn btn-sm" href="/deals">Deals →</a>
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small> </div>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')"> </header>
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select> <div class="clients-kpi-row">
</div> <div class="clients-kpi"><span>Totaal klanten</span><strong x-text="stats.total||0"></strong></div>
{% endfor %} <div class="clients-kpi"><span>Actief</span><strong x-text="stats.active||0"></strong></div>
</div> <div class="clients-kpi"><span>MRR schatting</span><strong x-text="fmtEur(stats.mrr_total)"></strong></div>
{% endfor %} <div class="clients-kpi"><span>Pipeline</span><strong x-text="fmtEur(stats.pipeline_value)"></strong></div>
</div> <div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
</div> </div>
</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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <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 { return {
modal: false, drawer: false, detail: '', tab: 'kanban',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' }, busy: false,
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; }, storeBusy: false,
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; }, clientModal: false,
async save() { storeCreateModal: false,
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes }; drawer: false,
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) }); stages: STAGES,
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) }); sectorOptions: SECTORS,
location.reload(); 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> </script>
+151 -45
View File
@@ -2,14 +2,25 @@
{% block extra_head %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <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/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 %} {% endblock %}
{% block content %} {% block content %}
<div class="herman-shell hm-neo"> <div class="herman-shell hm-neo">
<div class="herman-hero"> <div class="herman-hero">
<h1>Herman · Command Center <span class="live-badge">Live</span></h1> <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> </div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()"> <section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
@@ -24,6 +35,7 @@
<div class="briefing-meta" id="briefing-meta"></div> <div class="briefing-meta" id="briefing-meta"></div>
</div> </div>
<div class="herman-briefing-actions"> <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" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button> <button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a> <a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
@@ -32,29 +44,38 @@
</div> </div>
</header> </header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="briefing-dashboard" class="hm-dash-layout"> <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> <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> <div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section> </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> <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-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 id="briefing-executive-summary"></div>
</div> </div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div> <div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section> </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> <h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid"> <div class="briefing-summary-grid">
<div class="briefing-card summary"> <div class="briefing-card summary">
<h4>Samenvatting</h4> <h4>Samenvatting</h4>
<p id="briefing-summary-text" class="briefing-typewriter"></p> <p id="briefing-summary-text" class="briefing-typewriter"></p>
</div> </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="briefing-horizon-col">
<div class="horizon-card short"> <div class="horizon-card short">
<h4>⚡ Korte termijn</h4> <h4>⚡ Korte termijn</h4>
@@ -68,12 +89,14 @@
</div> </div>
</section> </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> <h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col"> <div class="hm-dash-two-col">
<div class="hm-chart-panel"> <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 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>
<div class="hm-chart-panel"> <div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div> <div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
@@ -86,72 +109,153 @@
</div> </div>
</section> </section>
<section class="hm-dash-section hm-dash-full"> <section class="hm-dash-section hm-dash-full hm-dash-analytics hm-widget-draggable" data-widget="analytics">
<h3 class="hm-section-title">Data & analytics</h3> <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-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-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></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>
<div class="hm-chart-panel"> <div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div> <div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></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>
<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-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></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>
<div class="hm-chart-panel"> <div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div> <div class="hm-chart-head"><h4>Agent activiteit</h4><small id="viz-label-agents">neo-bars</small></div>
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></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>
</div> </div>
</section> </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> </div>
</section> </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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% 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> <script>
const INITIAL_BRIEFING = {{ briefing_payload | tojson }}; const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() { function dashboardBriefing() {
return { return {
loading: false, loadingMsg: 'Herman werkt…', loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '', content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
_pollStop: null, _wsStop: null, _pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
async init() { 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.refreshLive(false);
await this.loadApprovals();
await this.loadProjectsMini();
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt); if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
window._refreshBriefingLive = (t) => this.refreshLive(t); 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) => { this._wsStop = CockpitLive.connectFeed((data) => {
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events); 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'})); 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) { renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini'); const el = document.getElementById('briefing-clients-mini');
if (!el) return; if (!el) return;
@@ -166,8 +270,10 @@ function dashboardBriefing() {
try { try {
const r = await fetch('/api/herman/briefing/stats').then(x => x.json()); const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
if (r.stats) { 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.renderClientsMini(r.stats);
this.renderActivityLog(r.stats.activity_log || []);
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC'; document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
} }
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success'); 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' }); const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
this.content = r.content || ''; this.content = r.content || '';
this.createdAt = r.generated_at || new Date().toISOString(); 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 || {}); this.renderClientsMini(r.stats || {});
Cockpit.toast('Dagrapport klaar!', 'success'); Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); } } catch (e) { Cockpit.toast(e.message, 'error'); }
+113 -29
View File
@@ -2,12 +2,24 @@
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %} {% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
{% block extra_head %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <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> <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-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
.doc-chart-panel { height: 140px; } .doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
.doc-chart-panel canvas { max-height: 110px !important; } @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-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 img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
.photo-card-body { padding:0.6rem; font-size:0.78rem; } .photo-card-body { padding:0.6rem; font-size:0.78rem; }
.detection-wrap { position:relative; display:inline-block; max-width:100%; } .detection-wrap { position:relative; display:inline-block; max-width:100%; }
@@ -15,17 +27,33 @@
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="documentsDash()" x-init="init()"> <div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
<header class="page-header"> <header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>Documents &amp; Foto's — interactief dashboard</h1> <h1>Documents &amp; Foto's <span class="live-badge">Neo</span></h1>
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p> <p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
</header> </header>
<div class="kpi-grid"> <div class="doc-neo-kpi-row">
<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="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
<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="doc-neo-kpi-label">Documenten</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="doc-neo-kpi-value" x-text="filteredDocs.length"></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="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>
<div class="doc-dash-filters panel"> <div class="doc-dash-filters panel">
@@ -46,14 +74,18 @@
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button> <button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
</div> </div>
<div class="grid-2" style="margin-top:1rem"> <div class="doc-chart-grid">
<section class="panel doc-chart-panel"> <section class="hm-chart-panel doc-chart-panel">
<h2>Top woorden <small class="muted">klik = filter</small></h2> <div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
<canvas id="wordsChart" height="110"></canvas> <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>
<section class="panel doc-chart-panel"> <section class="hm-chart-panel doc-chart-panel">
<h2>Sentiment <small class="muted">klik = filter</small></h2> <div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
<canvas id="sentimentChart" height="110"></canvas> <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> </section>
</div> </div>
@@ -132,8 +164,18 @@
</div> </div>
</section> </section>
<section class="panel" x-show="activePhoto" style="margin-top:1rem"> <section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2> <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"> <div class="detection-wrap">
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" /> <img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
<canvas id="detectionCanvas"></canvas> <canvas id="detectionCanvas"></canvas>
@@ -172,14 +214,28 @@ function documentsDash() {
activeOcr: '', activeOcr: '',
activeItems: [], activeItems: [],
detections: [], detections: [],
linkProjectId: '',
cockpitProjects: [],
liveRefresh: false, liveRefresh: false,
liveTimer: null, liveTimer: null,
init() { init() {
this.applyFilters(); this.applyFilters();
this.renderCharts(); this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos(); this.loadPhotos();
this.refreshShare(false); 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) { mergeShareItems(items) {
@@ -235,16 +291,44 @@ function documentsDash() {
}); });
}, },
renderCharts() { renderCharts(mode) {
if (wordsChartInst) wordsChartInst.destroy(); mode = mode || window.__vizMode || 'neo-bars';
if (sentimentChartInst) sentimentChartInst.destroy(); if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
const dash = this; 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'), { wordsChartInst = new Chart(document.getElementById('wordsChart'), {
type: 'bar', type: mode === 'neo-rings' ? 'polarArea' : 'bar',
data: { data: {
labels: topWords.slice(0, 10).map(w => w.lemma), labels: topWords.slice(0, 10).map(w => w.lemma),
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count), 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: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
@@ -255,11 +339,11 @@ function documentsDash() {
} }
}); });
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), { sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
type: 'doughnut', type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
data: { data: {
labels: ['Positief', 'Neutraal', 'Negatief'], labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [summary.positive, summary.neutral, summary.negative], datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }] backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
+13 -2
View File
@@ -3,9 +3,15 @@
<div x-data="hermanChat()" class="chat-layout"> <div x-data="hermanChat()" class="chat-layout">
<header class="page-header"> <header class="page-header">
<h1>Herman</h1> <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> </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;"> <div class="card card-compact" style="margin-bottom:1rem;">
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2> <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> <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 %} {% endfor %}
</div> </div>
<form class="chat-input" @submit.prevent="send"> <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> <button class="btn btn-primary" type="submit" :disabled="loading">Send</button>
</form> </form>
</div> </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;" />'; html += '<img src="' + r.image_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />';
this.lastImage = r.image_url; 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>'; html += '</div>';
log.innerHTML += html; log.innerHTML += html;
this.message = ''; this.message = '';
+75 -1
View File
@@ -1,4 +1,8 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<div x-data="marketingHub()" x-init="init()"> <div x-data="marketingHub()" x-init="init()">
<header class="page-header"> <header class="page-header">
@@ -9,9 +13,29 @@
<div class="retail-actions"> <div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button> <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 btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/projects">Projecten →</a>
</div> </div>
</header> </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="live-feed-bar" x-show="dashboard.rss?.length">
<div class="ticker-track"> <div class="ticker-track">
<template x-for="r in dashboard.rss" :key="r.id"> <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> <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"> <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"> <label class="btn btn-secondary btn-sm">
📷 Foto uploaden 📷 Foto uploaden
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" /> <input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
@@ -359,6 +389,8 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script> <script>
function marketingHub() { function marketingHub() {
return { return {
@@ -369,8 +401,9 @@ function marketingHub() {
promos: [], bookmarks: [], bookmarkIds: {}, promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' }, reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameStats: { chains: 0 }, 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, publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
cockpitProjects: [],
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨‍🍳','💚','📁','🎯'], emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨‍🍳','💚','📁','🎯'],
promoForm: { chain: '', title: '', folder_path: '', description: '' }, promoForm: { chain: '', title: '', folder_path: '', description: '' },
_pollStop: null, _pollStop: null,
@@ -385,11 +418,16 @@ function marketingHub() {
async init() { async init() {
await this.loadDashboard(); await this.loadDashboard();
await this.loadBookmarks(); 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 === 'regelgeving') await this.loadRegulations();
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); } if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); } if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000); this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
this.$nextTick(() => this.updateTabIndicator()); 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=>{ fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
const el=document.getElementById('reco-list'); const el=document.getElementById('reco-list');
if(!el) return; if(!el) return;
@@ -510,6 +548,7 @@ function marketingHub() {
try { try {
await fetch('/api/retail/rss/refresh', { method: 'POST' }); await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard(); await this.loadDashboard();
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success'); if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; } } finally { this.busy = false; }
}, },
@@ -540,8 +579,42 @@ function marketingHub() {
try { try {
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json()); const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
this.publishHistory = r.items || []; this.publishHistory = r.items || [];
if (window.__marketingRenderViz) window.__marketingRenderViz(window.__vizMode || 'neo-bars');
} catch (e) { this.publishHistory = []; } } 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) { publishResultSummary(j) {
const res = j.result || {}; const res = j.result || {};
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } } 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, image_url: this.publishForm.image_url || null,
media_ids: this.publishForm.media_ids || [], media_ids: this.publishForm.media_ids || [],
channels: this.publishForm.channels, channels: this.publishForm.channels,
project_id: this.publishForm.project_id ? parseInt(this.publishForm.project_id, 10) : null,
}; };
const r = await fetch('/api/marketing/publish', { const r = await fetch('/api/marketing/publish', {
method: 'POST', method: 'POST',
+190 -89
View File
@@ -1,131 +1,232 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block extra_head %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
<div class="ops-shell" x-data="opsTopology()" x-init="init()"> <div class="ops-shell hm-neo" x-data="opsPage()" x-init="init()">
<header class="ops-header"> <header class="ops-header herman-hero">
<div> <div>
<h1>IT Ops Infrastructure</h1> <h1>IT Ops · Dell R340 <span class="live-badge">Live</span></h1>
<p class="ops-subtitle">Realtime Proxmox + service health topology</p> <p class="hero-sub">PowerEdge R340 → Proxmox VE → VM 106 → online services · SysOps agent</p>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">Topology</button> <button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">Verversen</button>
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</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> </div>
</header> </header>
<section class="ops-kpis"> <div id="viz-toolbar" class="viz-toolbar"></div>
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div> <div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div> <div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div> <div class="hm-chart-head"><h4>Maintenance severity</h4><small>open items</small></div>
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></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> </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"> <section class="panel topology-card">
<svg viewBox="0 0 1200 600" class="topology-canvas"> <div class="ops-legend">
<g class="topology-lines"> <span><i class="dot dot-online"></i> Online / bereikbaar</span>
<template x-for="edge in edges" :key="edge.id"> <span><i class="dot dot-degraded"></i> Onbekend</span>
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line> <span><i class="dot dot-offline"></i> Offline</span>
</template> <span>Klik service-node → opent URL</span>
</g> </div>
<g> <svg id="ops-topology-canvas" viewBox="0 0 1200 620" class="topology-canvas"></svg>
<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>
</section> </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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% 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> <script>
function opsTopology() { function opsPage() {
return { return {
loading: false, loading: false,
summary: { counts: {} }, summary: { counts: {} },
nodes: [], meta: {},
edges: [], maintenance: [],
lastUpdate: null, backups: [],
sysopsActivity: [],
pendingBackup: null,
timer: null, timer: null,
positions: { renderViz(mode) {
'proxmox-host': { x: 600, y: 90 }, if (!window.VizEngine) return;
'vm105-hermes': { x: 340, y: 230 }, const sevMap = {};
'vm106-command': { x: 860, y: 230 }, (this.maintenance || []).forEach(n => {
'svc-gitea': { x: 190, y: 390 }, const s = n.severity || 'info';
'svc-ollama': { x: 480, y: 390 }, sevMap[s] = (sevMap[s] || 0) + 1;
'svc-cockpit': { x: 700, y: 390 }, });
'svc-tools-api': { x: 860, y: 390 }, let sevRows = Object.keys(sevMap).map(k => ({ label: k, value: sevMap[k], raw: sevMap[k] }));
'svc-email-agent': { x: 1020, y: 390 } 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() { async init() {
this.loadStatus(); await this.loadAll();
this.loadTopology(); await this.draw();
this.timer = setInterval(() => this.loadTopology(), 30000); 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() { async loadStatus() {
try { try {
const data = await fetch('/api/ops/status').then(r => r.json()); const data = await fetch('/api/ops/status').then(r => r.json());
this.summary = data || { counts: {} }; this.summary = data || { counts: {} };
this.lastUpdate = data.generated_at || new Date().toISOString(); this.meta = (data.topology && data.topology.meta) || data.sources || {};
} catch (e) { } catch (e) { console.warn(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; this.loading = true;
try { try {
const data = await fetch('/api/ops/topology').then(r => r.json()); await this.loadAll();
this.toGraph(data); await this.draw();
await this.loadStatus(); if (toast !== false && window.Cockpit) Cockpit.toast('IT Ops bijgewerkt', 'success');
} catch (e) { } finally { this.loading = false; }
console.warn(e);
} finally {
this.loading = false;
}
}, },
async refreshInfra() { async snapshot() {
this.loading = true; this.loading = true;
try { try {
await fetch('/api/ops/refresh', { method: 'POST' }); 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) { } catch (e) {
console.warn(e); if (window.Cockpit) Cockpit.toast('Snapshot mislukt', 'error');
} finally { } finally { this.loading = false; }
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> </script>
+333 -105
View File
@@ -1,92 +1,173 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Packaging Studio · Foodlinkk{% endblock %} {% block title %}Packaging Studio · Foodlinkk{% endblock %}
{% block extra_head %} {% block extra_head %}
<style> <link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
.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>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="packagingStudio()" x-init="init()"> <div class="pack-studio" x-data="packagingStudio()" x-init="init()">
<header class="page-header"> <header class="pack-hero">
<h1>Packaging Studio</h1> <div>
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p> <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> </header>
<div class="pack-grid"> <div class="pack-layout">
<section class="panel"> <aside class="pack-panel">
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;"> <div class="pack-tabs">
<label>Type <button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
<select class="form-input" x-model="form.type"> <button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
<option value="folding_box">Folding box</option> <button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
<option value="wrap">Wrap</option> <button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
<option value="round_label">Round label</option> <button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
</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> </div>
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2> <div x-show="sideTab==='shape'">
<div class="elements-grid"> <h3>Afmetingen &amp; type</h3>
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label> <div class="pack-field-grid">
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label> <label class="full">Design naam
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label> <input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</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>
<div class="pack-tools"> <div x-show="sideTab==='brand'" x-cloak>
<button class="btn btn-primary" @click="generate()" :disabled="loading"> <h3>Kleuren &amp; stijl</h3>
<span x-show="!loading">Genereer verpakking</span> <div class="pack-field-grid">
<span x-show="loading">Genereren…</span> <template x-for="c in colorFields" :key="c.key">
</button> <label class="full" x-text="c.label">
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a> <div class="pack-color-row">
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a> <input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a> <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> </div>
</section> </section>
<aside class="panel"> <aside class="pack-panel">
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2> <h3>Bibliotheek</h3>
<div class="project-list"> <div class="pack-library">
<template x-for="p in projects" :key="p.id"> <template x-for="p in library" :key="p.id">
<div class="project-item"> <article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
<strong x-text="p.spec.type"></strong> <strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small> <small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small> <small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button> </article>
</div>
</template> </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> </div>
</aside> </aside>
</div> </div>
<div class="panel" style="margin-top:1rem;"> <div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
<div class="pack-preview"> <div class="pack-modal" x-show="showEmailModal" x-cloak>
<div x-show="svg" x-html="svg"></div> <h3>✉ Design emailen</h3>
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p> <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> </div>
</div> </div>
@@ -94,58 +175,205 @@
{% block scripts %} {% block scripts %}
<script> <script>
function packagingStudio() { 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 { return {
loading: false, loading: false,
sideTab: 'shape',
svg: '', svg: '',
projectId: '', packagingId: '',
projects: [], 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: { form: {
type: 'folding_box', type: 'folding_box',
width_mm: 120, width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
height_mm: 80, barcode_value: '8710000000012', qr_value: '',
depth_mm: 40, design_name: 'Nieuw design',
barcode_value: '8710000000012', brand: { ...DEFAULT_BRAND },
brand: {}, text: {
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true } 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) { async init() {
if (!this.projectId) return '#'; await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
return '/api/packaging/download/' + this.projectId + '?format=' + format;
},
async refreshProjects() {
try { try {
const r = await Cockpit.api('/api/packaging/projects?limit=20'); const r = await Cockpit.api('/clients');
this.projects = r.items || []; 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) { } 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; payload() {
try { const body = JSON.parse(JSON.stringify(this.form));
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text()); if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
this.svg = data; else if (this.save.project_name) {
} catch (e) { body.project_name = this.save.project_name;
Cockpit.toast('Kon project niet laden', 'error'); 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; if (this.loading) return;
this.loading = true; this.loading = true;
try { try {
const r = await Cockpit.api('/api/packaging/generate', { const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
method: 'POST',
body: JSON.stringify(this.form)
});
this.svg = r.svg || ''; this.svg = r.svg || '';
this.projectId = r.id || ''; } catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
await this.refreshProjects();
Cockpit.toast('Packaging gegenereerd', 'success');
} catch (e) {
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
}
this.loading = false; 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" %} {% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %} {% block content %}
<div x-data="productsCrud()"> <div class="clients-shell" x-data="productsHub()" x-init="init()" @foodlinkk:locale.window="onLocale()">
<header class="page-header"><h1>Products</h1><p class="subtitle page-tip">SKU catalog with margin calculator.</p></header> <header class="page-header">
<button class="btn btn-primary" @click="show=true; id=null">Add product</button> <div>
<section class="panel"><h2>Margin calculator</h2> <h1 x-text="$t('products.title')">Productcatalogus</h1>
<div class="form-grid"><input type="number" class="form-input" x-model="margin.cost" placeholder="Cost" /> <p class="subtitle page-tip" x-text="$t('products.subtitle')"></p>
<input type="number" class="form-input" x-model="margin.sell" placeholder="Sell" /> </div>
<button class="btn btn-secondary" @click="calcMargin()">Calculate</button></div> <div class="retail-actions">
<p x-show="margin.result">Margin: <strong x-text="margin.result"></strong>%</p></section> <button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<table class="data-table"><thead><tr><th>Name</th><th>Status</th><th>Margin %</th><th></th></tr></thead> <a class="btn btn-sm" href="/clients" x-text="$t('nav.clients')"></a>
<tbody>{% for p in products %}<tr class="clickable-row" @click="edit({{ p.id }}, '{{ p.name|e }}', '{{ p.status|e }}', {{ p.margin_pct or 0 }})"> </div>
<td>{{ p.name }}</td><td>{{ p.status }}</td><td>{{ p.margin_pct }}</td> </header>
<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"> <div class="clients-kpi-row">
<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" /> <div class="clients-kpi"><span x-text="$t('products.total')"></span><strong x-text="stats.total||0"></strong></div>
<button class="btn btn-primary" @click="save()">Save</button></div></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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function productsCrud() { function productsHub() {
return { return {
show: false, id: null, form: { name: '', status: 'concept', margin_pct: 0, client_id: null }, tab: 'grid', busy: false, modal: false, drawer: false,
margin: { cost: 0, sell: 0, result: null }, stats: {}, products: [], filtered: [], clients: [], detail: null,
edit(id, name, status, margin_pct) { this.id=id; this.form={name,status,margin_pct,client_id:null}; this.show=true; }, filterQ: '', filterStatus: '',
async calcMargin() { form: {}, margin: { cost: 0, sell: 0, result: null, profit: null },
const r = await Cockpit.api('/products/margin-calc', { method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }) }); statuses: [],
this.margin.result = r.margin_pct;
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() { async save() {
const body = { ...this.form, margin_pct: parseFloat(this.form.margin_pct) }; const body = {
if (this.id) await Cockpit.api('/products/' + this.id, { method: 'PUT', body: JSON.stringify(body) }); name: this.form.name.trim(),
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) }); status: this.form.status,
location.reload(); 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> </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" %} {% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %} {% block content %}
<div x-data="suppliersCrud()"> <div class="clients-shell" x-data="suppliersHub()" x-init="init()">
<header class="page-header"><h1>Suppliers</h1><p class="subtitle page-tip">Vendor registry with ratings and lead times.</p></header> <header class="page-header">
<button class="btn btn-primary" @click="openNew()">Add supplier</button> <div>
<table class="data-table"><thead><tr><th>Name</th><th>Country</th><th>Rating</th><th></th></tr></thead> <h1 x-text="$t('suppliers.title')">Leveranciers</h1>
<tbody>{% for s in suppliers %}<tr class="clickable-row" @click="edit({{ s.id }}, '{{ s.name|e }}', '{{ (s.country or '')|e }}', {{ s.rating or 0 }})"> <p class="subtitle page-tip" x-text="$t('suppliers.subtitle')"></p>
<td>{{ s.name }}</td><td>{{ s.country }}</td><td>{{ s.rating }}</td> </div>
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ s.id }})">Delete</button></td></tr>{% endfor %}</tbody></table> <div class="retail-actions">
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card"> <button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
<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" /> <a class="btn btn-sm" href="/products" x-text="$t('nav.products')"></a>
<button class="btn btn-primary" @click="save()">Save</button></div></div> </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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function suppliersCrud() { function suppliersHub() {
return { show: false, id: null, form: { name: '', country: '', rating: 4, category: '', moq: null, lead_time_days: null, contact: '' }, return {
openNew() { this.id=null; this.form={ name:'', country:'', rating:4, category:'', moq:null, lead_time_days:null, contact:'' }; this.show=true; }, tab: 'grid', busy: false, modal: false, drawer: false,
edit(id,name,country,rating) { this.id=id; this.form={name,country,rating,category:'',moq:null,lead_time_days:null,contact:''}; this.show=true; }, stats: {}, suppliers: [], filtered: [], detail: null,
async save() { filterQ: '', filterCountry: '', filterCategory: '',
const body = { ...this.form, rating: parseFloat(this.form.rating) }; form: {}, categories: [], countryOptions: [],
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) }); buildCategories() {
location.reload(); 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> </script>
+37
View File
@@ -48,6 +48,43 @@ curl -sf -o /dev/null -w "marketing:%{http_code}\n" http://127.0.0.1:8600/market
curl -sf -o /dev/null -w "ops:%{http_code}\n" http://127.0.0.1:8600/ops curl -sf -o /dev/null -w "ops:%{http_code}\n" http://127.0.0.1:8600/ops
curl -sf -o /dev/null -w "packaging:%{http_code}\n" http://127.0.0.1:8600/packaging curl -sf -o /dev/null -w "packaging:%{http_code}\n" http://127.0.0.1:8600/packaging
curl -sf -o /dev/null -w "agents:%{http_code}\n" http://127.0.0.1:8600/agents curl -sf -o /dev/null -w "agents:%{http_code}\n" http://127.0.0.1:8600/agents
echo "=== NAS client folders root ==="
mkdir -p /home/aissa/nas-clients
echo "=== Gitea SysOps user ==="
chmod +x scripts/setup_gitea_sysops.sh 2>/dev/null || true
bash scripts/setup_gitea_sysops.sh || true
echo "=== SysOps + Herman cron ==="
(crontab -l 2>/dev/null || true | grep -v sysops_daily.py | grep -v herman_daily_digest.py | grep -v herman_platform_update.py; \
echo "0 7 * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/sysops_daily.py >> /tmp/sysops-daily.log 2>&1"; \
echo "30 7 * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_daily_digest.py >> /tmp/herman-daily.log 2>&1") | sort -u | crontab -
echo "Cron: SysOps 07:00, Herman briefing 07:30"
echo "=== Run SysOps daily now (test) ==="
cd "$REMOTE_DIR" && python3 scripts/sysops_daily.py || true
echo "=== Schedule Herman platform update over 2 uur ==="
if command -v at >/dev/null 2>&1; then
echo "cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_platform_update.py >> /tmp/herman-update.log 2>&1" | at now + 2 hours 2>&1 || true
echo "Gepland via 'at now + 2 hours'"
else
UPD_H=$(date -d '+2 hours' +%H 2>/dev/null || date -v+2H +%H 2>/dev/null || echo "09")
UPD_M=$(date -d '+2 hours' +%M 2>/dev/null || date -v+2H +%M 2>/dev/null || echo "00")
(crontab -l 2>/dev/null || true; echo "\$UPD_M \$UPD_H * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_platform_update.py >> /tmp/herman-update.log 2>&1 # once") | sort -u | crontab -
echo "Gepland via cron om \$UPD_H:\$UPD_M"
fi
echo "=== SysOps Gitea sync (volledige repo) ==="
docker exec foodlinkk_tools_api python3 -c "
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
import json
print(json.dumps(run_gitea_repo_sync('deploy-all'), indent=2))
" || echo "Gitea sync warning — check tools-api logs"
echo "=== Herman briefing nu genereren ==="
cd "$REMOTE_DIR" && python3 scripts/herman_daily_digest.py || true
REMOTE REMOTE
echo "=== Deploy voltooid ===" echo "=== Deploy voltooid ==="
+13
View File
@@ -16,6 +16,14 @@ services:
DB_USER: aissa DB_USER: aissa
DB_PASSWORD: Foodlinkk#2026 DB_PASSWORD: Foodlinkk#2026
DB_NAME: foodlinkk DB_NAME: foodlinkk
BACKUP_ROOT: /data/backup-root
GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
GITEA_SYSOPS_USER: sysops
GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
GITEA_SYSOPS_NAME: sysops
GITEA_BACKUP_BRANCH: main
PROXMOX_HOST: 10.4.7.14
VM106_IP: 10.4.7.18
OLLAMA_URL: http://10.4.7.19:11434 OLLAMA_URL: http://10.4.7.19:11434
OLLAMA_MODEL: qwen3:8b OLLAMA_MODEL: qwen3:8b
CHROMA_HOST: 10.4.7.19 CHROMA_HOST: 10.4.7.19
@@ -31,6 +39,8 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
ports: ports:
- "8700:8700" - "8700:8700"
volumes:
- /home/aissa/foodlinkk-command-center:/data/backup-root:ro
extra_hosts: extra_hosts:
- "foodlinkk_db:10.4.7.18" - "foodlinkk_db:10.4.7.18"
restart: unless-stopped restart: unless-stopped
@@ -53,8 +63,11 @@ services:
BROWSER_AGENT_URL: http://browser-agent:7790 BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188 COMFYUI_URL: http://10.4.7.18:8188
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090 HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
NAS_CLIENTS_ROOT: /data/nas-clients
ports: ports:
- "8600:8600" - "8600:8600"
volumes:
- /home/aissa/nas-clients:/data/nas-clients
depends_on: depends_on:
- tools-api - tools-api
extra_hosts: extra_hosts:
+7
View File
@@ -22,3 +22,10 @@ Module rechten voor Herman: `/settings?tab=permissions` — tabel `herman_permis
## Agent events ## Agent events
Marketing publish, reclamefolder sync en ops refreshes loggen naar `agent_events` voor traceerbaarheid. Marketing publish, reclamefolder sync en ops refreshes loggen naar `agent_events` voor traceerbaarheid.
## SysOps
- Dagelijks 07:00: approval requests (maintenance scan + backup)
- **Gitea:** volledige repo-sync bij deploy en na goedgekeurde backup
- Verslag: tabel `sysops_activity` + `/ops` UI + Herman briefing sectie "SysOps IT"
- Gitea user: `sysops` — zie `scripts/setup_gitea_sysops.sh`
+56
View File
@@ -0,0 +1,56 @@
# Foodlinkk Command Center — Context (voor Cursor / nieuwe machine)
> **Volledige documentatie:** [PLATFORM-AZ.md](./PLATFORM-AZ.md)
> **Gitea repo:** http://10.4.7.18:3001/aissa/foodlinkk-command-center
## Snel overzicht
| Item | Waarde |
|------|--------|
| VM106 | `10.4.7.18` |
| Cockpit | http://10.4.7.18:8600 |
| Tools API | http://10.4.7.18:8700 |
| Gitea | http://10.4.7.18:3001 |
| Code op server | `/home/aissa/foodlinkk-command-center` |
| NAS klanten | `/home/aissa/nas-clients` |
| Deploy | `bash deploy-all.sh` (rsync + migraties + docker + **Gitea push**) |
| SSH | `aissa@10.4.7.18` |
## Stack
- **Cockpit** — FastAPI + Jinja2 + Alpine.js (UI, proxy naar Tools API)
- **Tools API** — Connectors: retail, packaging, ops, email, CRM
- **PostgreSQL**`foodlinkk_db`, migraties `001``023`
- **16 agents + Herman** — souls, events, mesh, goedkeuringen
- **VM105** — Ollama `qwen3:8b`, Herman orchestrator `:8090`, ChromaDB
## SysOps & Gitea
- User **sysops** pusht naar Gitea bij elke **deploy-all**
- Goedgekeurde backup → zelfde volledige repo-sync
- Activiteit in `sysops_activity` + **IT Ops** tab + **Herman briefing**
- Cron 07:00 SysOps (approval scan/backup), 07:30 Herman briefing
## Belangrijkste features (2026)
- Agents: 3D avatars, klikbare events, SysOps met petje
- Packaging Studio + Herman `maak verpakking: ...`
- Projecten + NAS per klant (`packaging/`, `photos/`, …)
- Clients CRM + i18n NL/EN
- Herman dashboard: briefing, approvals, viz
## Cursor op andere laptop
```bash
git clone http://10.4.7.18:3001/aissa/foodlinkk-command-center.git
cd foodlinkk-command-center
cursor .
```
In chat: `@docs/CONTEXT.md` `@docs/PLATFORM-AZ.md`
## Taal & conventies
- UI default Nederlands (`cockpit/static/i18n/nl.json`)
- Geen commits zonder expliciete vraag
- Deploy altijd via `deploy-all.sh`
+2
View File
@@ -21,6 +21,8 @@ Het script:
2. Voert alle SQL migraties uit (`migrations/*.sql`) 2. Voert alle SQL migraties uit (`migrations/*.sql`)
3. Bouwt en herstart containers (`tools-api`, `cockpit`, `email-agent`, `browser-agent`) 3. Bouwt en herstart containers (`tools-api`, `cockpit`, `email-agent`, `browser-agent`)
4. Voert health checks uit 4. Voert health checks uit
5. **SysOps pusht volledige repo naar Gitea** (`main`, user `sysops`)
6. Genereert Herman briefing (activiteitenlog incl. SysOps)
## Omgevingsvariabelen ## Omgevingsvariabelen
+250
View File
@@ -0,0 +1,250 @@
# Foodlinkk Command Center — Platform A tot Z
*Laatst bijgewerkt: juni 2026 · VM106 `10.4.7.18`*
---
## A. Architectuur
```
Proxmox 10.4.7.14 (Dell R340)
├── VM105 10.4.7.19 — Ollama, ChromaDB, Herman orchestrator :8090
└── VM106 10.4.7.18 — Command Center (Docker)
├── cockpit :8600
├── tools-api :8700
├── gitea :3001
├── email-agent :8801
├── browser-agent :7790
├── prometheus :9090
├── grafana :3002
└── PostgreSQL foodlinkk_db (host)
```
**Dataflow:** Browser → Cockpit → (proxy) Tools API → PostgreSQL / externe APIs
---
## B. Bestandslocaties
| Pad | Inhoud |
|-----|--------|
| `/home/aissa/foodlinkk-command-center` | Volledige monorepo (live) |
| `/home/aissa/nas-clients` | Klant-NAS (gemount in cockpit) |
| `/tmp/sysops-daily.log` | SysOps cron log |
| `/tmp/herman-daily.log` | Herman briefing cron |
---
## C. Cockpit — alle pagina's
| Route | Module |
|-------|--------|
| `/` | Herman CEO dashboard |
| `/herman` | Herman chat (delegatie, packaging, foto's) |
| `/agents` | Souls, mesh, goedkeuringen, events |
| `/retail` | Retail 360 |
| `/marketing` | Social, reclame, RSS |
| `/clients` | CRM + supermarkten |
| `/products`, `/suppliers` | Assortiment & inkoop |
| `/projects` | Food-business projecten + NAS |
| `/packaging` | Packaging Studio |
| `/ops` | IT topology, SysOps verslag, Gitea |
| `/documents` | Documenten hub |
| `/settings` | Email, social, Herman permissions |
| `/studio` | ComfyUI |
| `/beurs` | Marktdata |
---
## D. Database (PostgreSQL)
Migraties in `migrations/`:
| # | Onderwerp |
|---|-----------|
| 001 | agent_events, clients |
| 005008 | email, browser, pgvector |
| 011017 | retail 360, CRM |
| 016 | packaging agent, sysops soul |
| 018 | agent_action_requests (goedkeuringen) |
| 019 | cockpit_projects, project_assets |
| 021 | locale_preferences (i18n) |
| 022 | NAS-structuur per klant |
| 023 | sysops_activity log |
**Kern-tabellen:** `agent_souls`, `agent_events`, `agent_action_requests`, `cockpit_projects`, `project_assets`, `packaging_outputs`, `config_backups`, `sysops_activity`, `clients`, `supermarkets`, `deals`
---
## E. Agents (16 + Herman)
| Key | Rol |
|-----|-----|
| herman | Co-CEO, briefing, orchestratie |
| sysops | IT, Gitea sync, maintenance |
| packaging | Verpakkingsontwerp |
| marketing | Social, campagnes |
| retail | Supermarkten 360 |
| research | Marktonderzoek |
| design | ComfyUI |
| … | bizdev, finance, sourcing, product, halal, knowledge, browser, hermes, email |
**Events:** `agent_events` — zichtbaar op `/agents`, klikbaar per agent.
**Goedkeuringen:** SysOps vraagt vóór maintenance scan en optionele backup.
---
## F. SysOps — Gitea & verslag
### Automatische Gitea push
Bij **elke deploy** (`deploy-all.sh`):
1. Rsync code naar VM106
2. Docker rebuild
3. **SysOps pusht volledige repo** naar Gitea `main` (user `sysops`)
API: `POST /ops/gitea/sync` body `{"reason":"manual"}`
### Goedgekeurde backup
Na CEO ✓ op approval `config_backup` → zelfde volledige repo-sync.
### Activiteitenlog
Tabel `sysops_activity`:
- `gitea_sync` — deploy, backup
- `maintenance_scan` — na scan
Zichtbaar op **IT Ops** (`/ops`) → paneel "SysOps verslag".
### Herman briefing
`collect_briefing_data()` leest:
- `sysops_activity_24h`
- `sysops_events_24h`
- Sectie **"SysOps IT — laatste 24 uur"** in dagrapport
Cron: 07:00 SysOps requests, 07:30 Herman briefing.
---
## G. Gitea
- URL: http://10.4.7.18:3001
- Repo: `aissa/foodlinkk-command-center`
- SysOps user: `sysops` (write, setup via `scripts/setup_gitea_sysops.sh`)
- Documentatie staat **in de repo** onder `docs/`
**Clone op andere machine:**
```bash
git clone http://10.4.7.18:3001/aissa/foodlinkk-command-center.git
```
---
## H. Herman
- Chat: `/api/herman/chat`
- Briefing: `POST /api/herman/briefing`
- Orchestrator: `HERMAN_ORCHESTRATOR_URL` → VM105 `:8090`
- Packaging opdracht: `maak verpakking: pouch 100×140 voor Product X project #5`
---
## I. Packaging
- UI: `/packaging` — 6 types, brand, tekst, NAS-export
- API: Tools API `/packaging/*`
- NAS: `{klant}/projects/{id}/packaging/*.svg|png|pdf|json`
- Herman: `packaging_agent.py` → generate + events
---
## J. Projecten & NAS
Per klant: `/home/aissa/nas-clients/{slug}/`
Per project: `projects/{id-naam}/photos|documents|packaging|exports|briefs/`
API: `/api/projects` — create met `ensure_nas: true`
---
## K. Deploy
```bash
# Vanaf dev machine met /tmp/foodlinkk-deploy of lokale clone:
bash deploy-all.sh
```
Stappen: rsync → migraties → docker build → health checks → Gitea sync → Herman briefing.
Omgevingsvariabelen: `.env.example` (niet committen).
---
## L. i18n
- `cockpit/static/i18n/nl.json`, `en.json`
- Selector in topbar
- Uitgebreid: nav, clients, products, suppliers
---
## M. Monitoring
- Prometheus `:9090`, Grafana `:3002`
- IT Ops topology: Proxmox + services live
- Infra snapshots: `infra_snapshots`
---
## N. Email
- Settings → SMTP accounts
- Tools API `POST /emails/send`
- Packaging kan designs emailen met downloadlinks
---
## O. Ontwikkeling
| Component | Tech |
|-----------|------|
| Cockpit | Python 3.11, FastAPI, Jinja2, Alpine.js |
| Tools API | FastAPI, psycopg2 |
| Viz | Chart.js, viz-engine.js |
| Packaging | svgwrite, python-barcode, Pillow, reportlab |
**Tests:** health curls in `deploy-all.sh` en `docs/DEPLOY.md`
---
## P. Veiligheid
- SysOps: geen destructieve acties zonder goedkeuring
- `.env` in `.gitignore`
- Gitea: geen public registration
- Wachtwoorden in docker-compose op server (niet in git idealiter — gebruik `.env`)
---
## Q. Troubleshooting
| Probleem | Actie |
|----------|-------|
| Cockpit 502 | `docker compose logs cockpit` |
| Gitea push faalt | `scripts/setup_gitea_sysops.sh`, check collaborator |
| Briefing leeg | `python3 scripts/herman_daily_digest.py` |
| NAS map ontbreekt | Project → ensure-nas of opnieuw aanmaken |
---
## R. Referenties
- [ARCHITECTURE.md](./ARCHITECTURE.md)
- [APPS.md](./APPS.md)
- [API.md](./API.md)
- [AGENTS.md](./AGENTS.md)
- [DEPLOY.md](./DEPLOY.md)
- [CONTEXT.md](./CONTEXT.md) — korte Cursor-context
@@ -0,0 +1,15 @@
-- RetailDetail Food category feed + trending focus
UPDATE rss_feeds SET
name = 'RetailDetail Food',
url = 'https://www.retaildetail.nl/category/food/feed/',
category = 'food',
is_active = TRUE
WHERE name IN ('RetailDetail', 'RetailDetail Food');
INSERT INTO rss_feeds (name, url, category, is_active) VALUES
('RetailDetail Food', 'https://www.retaildetail.nl/category/food/feed/', 'food', TRUE)
ON CONFLICT (name) DO UPDATE SET
url = EXCLUDED.url,
category = EXCLUDED.category,
is_active = TRUE;
+45
View File
@@ -0,0 +1,45 @@
-- Agent action approval workflow (query gate before execution)
CREATE TABLE IF NOT EXISTS agent_action_requests (
id SERIAL PRIMARY KEY,
agent_key VARCHAR(64) NOT NULL,
action_type VARCHAR(64) NOT NULL DEFAULT 'query',
title VARCHAR(255) NOT NULL,
query_payload JSONB DEFAULT '{}'::jsonb,
status VARCHAR(32) DEFAULT 'pending',
approved_by VARCHAR(64),
rejection_reason TEXT,
result JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
reviewed_at TIMESTAMPTZ,
executed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_agent_action_requests_status ON agent_action_requests(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_action_requests_agent ON agent_action_requests(agent_key, created_at DESC);
UPDATE agent_souls SET
role_title = 'Co-CEO · Takenverdeler',
soul_md = '# Herman — Soul
Je bent Herman, AI Co-CEO van Foodlinkk. Warm, direct, zakelijk.
## Karakter
- Co-CEO naast Aissa (CEO) delegeert taken naar gespecialiseerde agents
- Alle agent-output wordt geaggregeerd; jij rapporteert naar CEO en CTO
- Geen autonome acties zonder goedgekeurde approval requests
## Werkzaamheden
- CEO briefing · orchestratie · mesh hub · goedkeuringsflow bewaken
- Taken verdelen over retail, marketing, ops, research, packaging
## Samenwerking
- Agents Herman CEO / CTO
- Externe queries: altijd via approval queue',
responsibilities = 'Co-CEO · takenverdeler · briefing · orchestratie · mesh hub · approvals'
WHERE agent_key = 'herman';
UPDATE agent_souls SET
role_title = 'Markt Research · Intel',
responsibilities = 'Web research · concurrenten · retail trends · markt intel (via approval)'
WHERE agent_key = 'research';
@@ -0,0 +1,56 @@
-- Unified projects, assets linking, UI preferences (layout + viz modes)
CREATE TABLE IF NOT EXISTS cockpit_projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
description TEXT,
status VARCHAR(32) DEFAULT 'active',
created_by VARCHAR(64) DEFAULT 'ceo',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_client ON cockpit_projects(client_id);
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_status ON cockpit_projects(status, updated_at DESC);
CREATE TABLE IF NOT EXISTS project_assets (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES cockpit_projects(id) ON DELETE CASCADE,
asset_type VARCHAR(64) NOT NULL,
ref_id VARCHAR(128),
title VARCHAR(255),
file_path TEXT,
payload JSONB DEFAULT '{}'::jsonb,
created_by VARCHAR(64) DEFAULT 'ceo',
source_agent VARCHAR(64),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_project_assets_project ON project_assets(project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_project_assets_type ON project_assets(asset_type, ref_id);
CREATE TABLE IF NOT EXISTS packaging_outputs (
id VARCHAR(64) PRIMARY KEY,
project_id INTEGER REFERENCES cockpit_projects(id) ON DELETE SET NULL,
spec JSONB NOT NULL,
svg TEXT NOT NULL,
created_by VARCHAR(64) DEFAULT 'packaging',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_ui_preferences (
user_key VARCHAR(64) PRIMARY KEY DEFAULT 'ceo',
dashboard_layout JSONB DEFAULT '[]'::jsonb,
viz_modes JSONB DEFAULT '{}'::jsonb,
global_viz_mode VARCHAR(32) DEFAULT 'neo-bars',
updated_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode) VALUES
('ceo', '["kpis","executive","briefing","retail","analytics","approvals","feed"]'::jsonb, 'neo-bars')
ON CONFLICT (user_key) DO NOTHING;
ALTER TABLE photo_imports ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES cockpit_projects(id) ON DELETE SET NULL;
ALTER TABLE photo_imports ADD COLUMN IF NOT EXISTS created_by VARCHAR(64) DEFAULT 'ceo';
+58
View File
@@ -0,0 +1,58 @@
-- SysOps daily config backup, maintenance notes, permissions
CREATE TABLE IF NOT EXISTS config_backups (
id SERIAL PRIMARY KEY,
approval_request_id INTEGER REFERENCES agent_action_requests(id) ON DELETE SET NULL,
commit_ref VARCHAR(128),
files_count INTEGER DEFAULT 0,
status VARCHAR(32) DEFAULT 'pending',
message TEXT,
payload JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_config_backups_created ON config_backups(created_at DESC);
CREATE TABLE IF NOT EXISTS ops_maintenance_notes (
id SERIAL PRIMARY KEY,
severity VARCHAR(16) DEFAULT 'info',
title VARCHAR(255) NOT NULL,
body TEXT,
source VARCHAR(64) DEFAULT 'sysops',
resolved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ops_maintenance_created ON ops_maintenance_notes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ops_maintenance_open ON ops_maintenance_notes(resolved, created_at DESC);
UPDATE agent_souls SET
soul_md = '# SysOps — Soul
Je bewaakt en onderhoudt de Foodlinkk IT-omgeving op **Dell PowerEdge R340** met **Proxmox VE**.
## Karakter
- Nuchter, alert, rapporteert feiten zonder paniek
- Geen destructieve acties zonder CEO-goedkeuring via approval queue
## Werkzaamheden
- Proxmox node + VM106 (Command Center) monitoren
- Docker container health op 10.4.7.18
- **Dagelijks config-backup naar Gitea** (na CEO approval)
- Dagelijks maintenance-rapport in IT Ops tab
- Infra topologie live bijhouden
## Rechten
- `config_backup` backup docker-compose, migraties, monitoring naar Gitea
- `proxmox_read` status uitlezen via Proxmox API/SSH
- `maintenance_report` updates/patches rapporteren
## Samenwerking
- Herman: infra alerts en goedkeuringen
- Research: geen infra-mutaties',
responsibilities = 'Proxmox R340 · VM106 · Docker · dagelijkse Gitea backup · maintenance',
permissions = '{"config_backup": true, "proxmox_read": true, "gitea_push": true, "maintenance_report": true}'::jsonb,
updated_at = NOW()
WHERE agent_key = 'sysops';
INSERT INTO herman_permissions (module_key, module_label, description, granted) VALUES
('ops', 'IT Ops', 'Infra topologie, backups, maintenance', true)
ON CONFLICT (module_key) DO UPDATE SET granted = true, module_label = EXCLUDED.module_label;
+3
View File
@@ -0,0 +1,3 @@
-- UI locale preference (nl | en)
ALTER TABLE user_ui_preferences ADD COLUMN IF NOT EXISTS locale VARCHAR(8) DEFAULT 'nl';
UPDATE user_ui_preferences SET locale = 'nl' WHERE locale IS NULL;
+11
View File
@@ -0,0 +1,11 @@
-- Projects: type, NAS paths, client folder roots
ALTER TABLE clients ADD COLUMN IF NOT EXISTS nas_folder TEXT;
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS project_type VARCHAR(64) DEFAULT 'general';
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS nas_path TEXT;
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS nas_client_root TEXT;
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS priority VARCHAR(16) DEFAULT 'normal';
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_type ON cockpit_projects(project_type, status);
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_client ON cockpit_projects(client_id, updated_at DESC);
+16
View File
@@ -0,0 +1,16 @@
-- SysOps activiteitenlog — verslagen voor IT Ops UI en Herman briefing
CREATE TABLE IF NOT EXISTS sysops_activity (
id SERIAL PRIMARY KEY,
action_type VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
commit_ref VARCHAR(128),
files_changed INTEGER DEFAULT 0,
status VARCHAR(32) DEFAULT 'success',
source VARCHAR(64) DEFAULT 'sysops',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sysops_activity_created ON sysops_activity(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sysops_activity_type ON sysops_activity(action_type, created_at DESC);
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Generate Herman daily CEO briefing with full activity documentation."""
from __future__ import annotations
import json
import sys
import urllib.request
def main() -> int:
url = "http://127.0.0.1:8600/api/herman/briefing"
req = urllib.request.Request(url, data=b"{}", headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.loads(resp.read().decode("utf-8"))
print("Herman briefing generated:", (data.get("generated_at") or "")[:19])
print("Stats pending approvals:", (data.get("stats") or {}).get("pending_approvals"))
return 0
except Exception as exc:
print("Herman briefing failed:", exc, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Scheduled platform update briefing for Herman — documents all recent platform work."""
from __future__ import annotations
import json
import sys
import urllib.request
from datetime import datetime, timezone
PLATFORM_UPDATE = """
Platform update Foodlinkk Command Center:
1. Projecten unified opslag voor foto's, packaging, agent-output met klantkoppeling
2. Herman dashboard widgets slepen, 5 viz-modi, agent goedkeuringen op dashboard
3. IT Ops Dell R340 topologie, SysOps dagelijkse backup + update-scan via approval
4. Agent assets marketing publish, research briefs, approvals auto project koppeling
5. Viz engine Marketing, Agents, IT Ops pagina's met 5 visualisatiestijlen
6. SysOps workflow vraagt nu eerst goedkeuring voor update-scan vóór uitvoering
7. Herman briefing documenteert dagelijks alle agent-acties in activiteitenlog
"""
def main() -> int:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"=== Herman platform update briefing {now} ===")
try:
req = urllib.request.Request(
"http://127.0.0.1:8600/api/herman/briefing",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=180) as resp:
briefing = json.loads(resp.read().decode("utf-8"))
print("Briefing OK — generated_at:", (briefing.get("generated_at") or "")[:19])
except Exception as exc:
print("Briefing generation failed:", exc, file=sys.stderr)
return 1
print(PLATFORM_UPDATE)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Maak SysOps Gitea-gebruiker aan (login: sysops) en geef write op backup-repo.
set -euo pipefail
GITEA_URL="${GITEA_URL:-http://127.0.0.1:3001}"
ADMIN_USER="${GITEA_ADMIN_USER:-aissa}"
ADMIN_PASS="${GITEA_ADMIN_PASS:-Foodlinkk#2026}"
SYSOPS_USER="${GITEA_SYSOPS_USER:-sysops}"
SYSOPS_PASS="${GITEA_SYSOPS_PASS:-Foodlinkk#SysOps2026}"
SYSOPS_EMAIL="${GITEA_SYSOPS_EMAIL:-sysops@foodlinkk.local}"
REPO_OWNER="${GITEA_REPO_OWNER:-aissa}"
REPO_NAME="${GITEA_REPO_NAME:-foodlinkk-command-center}"
CONTAINER="${GITEA_CONTAINER:-foodlinkk_gitea}"
echo "=== Gitea SysOps setup ($SYSOPS_USER) ==="
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
if ! docker exec "$CONTAINER" gitea admin user list 2>/dev/null | grep -q "^${SYSOPS_USER} "; then
echo "Creating user $SYSOPS_USER..."
docker exec "$CONTAINER" gitea admin user create \
--username "$SYSOPS_USER" \
--password "$SYSOPS_PASS" \
--email "$SYSOPS_EMAIL" \
--must-change-password=false 2>/dev/null || true
else
echo "User $SYSOPS_USER exists — updating password..."
docker exec "$CONTAINER" gitea admin user change-password \
--username "$SYSOPS_USER" \
--password "$SYSOPS_PASS" 2>/dev/null || true
fi
else
echo "Gitea container not running — skip user create (will retry on deploy)"
fi
enc_pass() {
python3 -c "import urllib.parse; print(urllib.parse.quote('''$1''', safe=''))"
}
SYSOPS_PASS_ENC=$(enc_pass "$SYSOPS_PASS")
AUTH=$(printf '%s:%s' "$ADMIN_USER" "$ADMIN_PASS" | base64 -w0 2>/dev/null || printf '%s:%s' "$ADMIN_USER" "$ADMIN_PASS" | base64)
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X PUT "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/collaborators/${SYSOPS_USER}" \
-H "Authorization: Basic ${AUTH}" \
-H "Content-Type: application/json" \
-d '{"permission":"write"}' 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "204" ] || [ "$HTTP_CODE" = "201" ]; then
echo "Collaborator $SYSOPS_USER → write on ${REPO_OWNER}/${REPO_NAME}"
elif [ "$HTTP_CODE" = "422" ]; then
echo "Collaborator already configured"
else
echo "Note: collaborator API returned HTTP $HTTP_CODE (repo may not exist yet)"
fi
REMOTE="http://${SYSOPS_USER}:${SYSOPS_PASS_ENC}@gitea:3001/${REPO_OWNER}/${REPO_NAME}.git"
echo "GITEA_BACKUP_REMOTE=$REMOTE"
echo "=== SysOps kan pushen als $SYSOPS_USER ==="
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""SysOps daily — approval requests for update scan + Gitea backup (no auto-scan)."""
from __future__ import annotations
import json
import sys
import urllib.request
from datetime import datetime, timezone
COCKPIT_URL = "http://127.0.0.1:8600"
def _post(url: str, payload: dict | None = None, timeout: int = 60) -> dict:
data = json.dumps(payload or {}).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _get(url: str, timeout: int = 30) -> dict:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _already_pending(action_type: str) -> bool:
try:
data = _get(f"{COCKPIT_URL}/api/agents/approvals?status=pending&limit=50")
return any(
i.get("agent_key") == "sysops" and i.get("action_type") == action_type
for i in (data.get("items") or [])
)
except Exception:
return False
def main() -> int:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
results: dict = {"date": today, "steps": []}
if not _already_pending("maintenance_scan"):
try:
scan_req = _post(
f"{COCKPIT_URL}/api/agents/requests",
{
"agent_key": "sysops",
"action_type": "maintenance_scan",
"title": f"Mag ik een update-scan uitvoeren op VM106? — {today}",
"query_payload": {
"scope": "vm106",
"checks": ["apt upgrades", "disk usage", "docker health", "proxmox reachability"],
"host": "10.4.7.18",
},
},
)
results["steps"].append({"maintenance_scan_request": scan_req})
print("Maintenance scan approval request:", (scan_req.get("request") or {}).get("id"))
except Exception as exc:
results["steps"].append({"maintenance_scan_error": str(exc)})
print("Maintenance scan request failed:", exc, file=sys.stderr)
else:
print("Maintenance scan request already pending — skipped")
results["steps"].append({"maintenance_scan_request": "already_pending"})
if not _already_pending("config_backup"):
try:
backup_req = _post(
f"{COCKPIT_URL}/api/agents/requests",
{
"agent_key": "sysops",
"action_type": "config_backup",
"title": f"Dagelijkse config backup naar Gitea — {today}",
"query_payload": {
"target": "gitea",
"repo": "aissa/foodlinkk-command-center",
"paths": ["docker-compose.yml", "migrations", "monitoring"],
},
},
)
results["steps"].append({"backup_request": backup_req})
print("Backup approval request:", (backup_req.get("request") or {}).get("id"))
except Exception as exc:
results["steps"].append({"backup_error": str(exc)})
print("Backup request failed:", exc, file=sys.stderr)
return 1
else:
print("Backup request already pending — skipped")
results["steps"].append({"backup_request": "already_pending"})
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -1
View File
@@ -1,6 +1,8 @@
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 git openssh-client sshpass ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
+144
View File
@@ -0,0 +1,144 @@
"""Daily configuration backup to Gitea — executed after CEO approval."""
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.db import execute, fetch_all, fetch_one
BACKUP_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
GITEA_REMOTE = os.getenv(
"GITEA_BACKUP_REMOTE",
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
)
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
BACKUP_PATHS = [
"docker-compose.yml",
".env.example",
"migrations",
"monitoring",
"cockpit/requirements.txt",
"tools-api/requirements.txt",
"deploy-all.sh",
"README.md",
]
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> dict[str, Any]:
try:
proc = subprocess.run( # noqa: S603
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "cmd": cmd}
except FileNotFoundError as exc:
return {"ok": False, "error": str(exc), "cmd": cmd}
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"cmd": cmd,
}
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def _record_backup(
status: str,
message: str,
files_count: int = 0,
commit_ref: str | None = None,
approval_request_id: int | None = None,
payload: dict | None = None,
) -> dict[str, Any] | None:
if not _table_exists("config_backups"):
return None
row = fetch_one(
"""
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, created_at
""",
(
approval_request_id,
commit_ref,
files_count,
status,
message,
json.dumps(payload or {}),
),
)
return dict(row) if row else None
def list_backups(limit: int = 20) -> list[dict[str, Any]]:
if not _table_exists("config_backups"):
return []
rows = fetch_all(
"SELECT * FROM config_backups ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def prepare_backup_manifest() -> dict[str, Any]:
root = BACKUP_ROOT
files: list[str] = []
if root.exists():
for rel in BACKUP_PATHS:
p = root / rel
if p.is_file():
files.append(rel)
elif p.is_dir():
for fp in p.rglob("*"):
if fp.is_file() and not any(x in str(fp) for x in [".git", "__pycache__", ".pyc"]):
files.append(str(fp.relative_to(root)))
return {
"root": str(root),
"root_exists": root.exists(),
"files": sorted(files)[:500],
"files_count": len(files),
"paths_configured": BACKUP_PATHS,
"generated_at": _iso_now(),
}
def run_gitea_backup(approval_request_id: int | None = None) -> dict[str, Any]:
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=approval_request_id)
+248
View File
@@ -0,0 +1,248 @@
"""Volledige codebase sync naar Gitea main — SysOps."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.db import execute, fetch_all, fetch_one
from app.middleware import log_agent_event
REPO_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
GITEA_REMOTE = os.getenv(
"GITEA_BACKUP_REMOTE",
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
)
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
SKIP_NAMES = {
".git",
"__pycache__",
".env",
"node_modules",
".cursor",
".venv",
"venv",
"*.pyc",
}
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 180) -> dict[str, Any]:
try:
proc = subprocess.run( # noqa: S603
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "cmd": cmd}
except FileNotFoundError as exc:
return {"ok": False, "error": str(exc), "cmd": cmd}
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"cmd": cmd,
}
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def _record_activity(
action_type: str,
title: str,
body: str = "",
commit_ref: str | None = None,
files_changed: int = 0,
status: str = "success",
metadata: dict | None = None,
) -> dict[str, Any] | None:
if not _table_exists("sysops_activity"):
return None
row = fetch_one(
"""
INSERT INTO sysops_activity (action_type, title, body, commit_ref, files_changed, status, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, created_at
""",
(action_type, title, body, commit_ref, files_changed, status, json.dumps(metadata or {})),
)
return dict(row) if row else None
def list_activity(limit: int = 30) -> list[dict[str, Any]]:
if not _table_exists("sysops_activity"):
return []
rows = fetch_all(
"SELECT * FROM sysops_activity ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def _should_skip(name: str) -> bool:
if name in SKIP_NAMES:
return True
if name.endswith(".pyc") or name.endswith(".pyo"):
return True
return False
def _copy_tree(src: Path, dest: Path) -> int:
"""Kopieer repo-inhoud naar git worktree; retour aantal bestanden."""
count = 0
if not src.exists():
return 0
for item in src.iterdir():
if _should_skip(item.name):
continue
target = dest / item.name
if item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
count += 1
elif item.is_dir():
if target.exists():
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(
item,
target,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".env", "node_modules", ".git"),
)
count += sum(1 for _ in target.rglob("*") if _.is_file())
return count
def run_gitea_repo_sync(reason: str = "deploy", approval_request_id: int | None = None) -> dict[str, Any]:
"""Push volledige codebase naar Gitea main branch."""
work = Path("/tmp/foodlinkk-gitea-sync")
if work.exists():
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True, exist_ok=True)
if not REPO_ROOT.exists():
msg = f"Repo root niet gevonden: {REPO_ROOT}"
_record_activity("gitea_sync", "Gitea sync mislukt", msg, status="failed")
return {"ok": False, "message": msg}
repo = work / "repo"
clone = _run(["git", "clone", "--depth", "1", "-b", GITEA_BRANCH, GITEA_REMOTE, str(repo)])
if not clone.get("ok"):
repo = work
_run(["git", "init", "-b", GITEA_BRANCH], cwd=repo)
_run(["git", "remote", "add", "origin", GITEA_REMOTE], cwd=repo)
_run(["git", "config", "user.email", GITEA_GIT_EMAIL], cwd=repo)
_run(["git", "config", "user.name", GITEA_GIT_NAME], cwd=repo)
copied = _copy_tree(REPO_ROOT, repo)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
commit_msg = f"SysOps: {reason}{stamp}"
_run(["git", "add", "-A"], cwd=repo)
commit = _run(["git", "commit", "-m", commit_msg], cwd=repo)
nothing = "nothing to commit" in (commit.get("stdout", "") + commit.get("stderr", ""))
if not commit.get("ok") and not nothing:
msg = "Git commit mislukt: " + (commit.get("stderr") or "")
_record_activity("gitea_sync", "Gitea commit mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
commit_ref = None
if not nothing:
push = _run(["git", "push", "-u", "origin", GITEA_BRANCH], cwd=repo, timeout=300)
if not push.get("ok"):
msg = "Git push mislukt: " + (push.get("stderr") or "")
_record_activity("gitea_sync", "Gitea push mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
rev = _run(["git", "rev-parse", "--short", "HEAD"], cwd=repo)
commit_ref = rev.get("stdout") or None
summary = (
f"Volledige codebase gesynchroniseerd naar Gitea ({reason}). "
f"{copied} bestanden · commit {commit_ref or 'geen wijzigingen'}"
)
activity = _record_activity(
"gitea_sync",
f"Gitea push: {reason}",
summary,
commit_ref=commit_ref,
files_changed=copied,
status="success" if (commit_ref or nothing) else "skipped",
metadata={"reason": reason, "approval_request_id": approval_request_id, "nothing_to_commit": nothing},
)
try:
log_agent_event(
agent_name="sysops",
agent_type="sysops",
event_type="gitea_sync",
title=f"Gitea sync: {reason}" + (f" ({commit_ref})" if commit_ref else " — geen wijzigingen"),
body=summary,
metadata={
"commit_ref": commit_ref,
"files_changed": copied,
"reason": reason,
"for_herman": True,
},
channel="sysops",
)
except Exception:
pass
if _table_exists("config_backups"):
fetch_one(
"""
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
approval_request_id,
commit_ref,
copied,
"success" if commit_ref else "unchanged",
summary,
json.dumps({"reason": reason, "full_repo": True}),
),
)
return {
"ok": True,
"message": summary,
"commit_ref": commit_ref,
"files_count": copied,
"nothing_to_commit": nothing,
"activity_id": (activity or {}).get("id"),
}
+179
View File
@@ -0,0 +1,179 @@
"""Daily maintenance checks for IT Ops tab."""
from __future__ import annotations
import json
import subprocess
from datetime import datetime, timezone
from typing import Any
from app.db import execute, fetch_all, fetch_one
from app.connectors.proxmox import PROXMOX_HOST, VM_106_IP, _port_health, _run_ssh, check_docker_services
SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def list_notes(limit: int = 20, unresolved_only: bool = True) -> list[dict[str, Any]]:
if not _table_exists("ops_maintenance_notes"):
return []
clauses = ["resolved = false"] if unresolved_only else []
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"SELECT * FROM ops_maintenance_notes{where} ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def add_note(title: str, body: str = "", severity: str = "info", source: str = "sysops") -> dict[str, Any] | None:
if not _table_exists("ops_maintenance_notes"):
return None
row = fetch_one(
"""
INSERT INTO ops_maintenance_notes (severity, title, body, source)
VALUES (%s, %s, %s, %s)
RETURNING *
""",
(severity, title[:255], body, source),
)
if not row:
return None
item = dict(row)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
return item
def run_maintenance_scan() -> dict[str, Any]:
findings: list[dict[str, Any]] = []
proxmox_up = _port_health(PROXMOX_HOST, 8006)
if not proxmox_up:
findings.append({
"severity": "critical",
"title": "Proxmox Web UI niet bereikbaar",
"body": f"Poort 8006 op {PROXMOX_HOST} reageert niet. Controleer Dell R340 / Proxmox VE.",
})
docker = check_docker_services()
if not docker.get("ok"):
findings.append({
"severity": "warning",
"title": "Docker status onbekend",
"body": docker.get("error") or "Kon docker ps niet uitvoeren op VM106.",
})
else:
containers = docker.get("containers") or []
unhealthy = [
c for c in containers
if "unhealthy" in str(c.get("Status") or c.get("State") or "").lower()
]
if unhealthy:
names = ", ".join(str(c.get("Names") or c.get("Name") or "?")[:40] for c in unhealthy[:5])
findings.append({
"severity": "warning",
"title": f"{len(unhealthy)} container(s) unhealthy",
"body": names,
})
apt = _run_ssh(VM_106_IP, "apt list --upgradable 2>/dev/null | grep -v Listing | wc -l")
if apt.get("ok"):
try:
count = int((apt.get("stdout") or "0").strip())
except ValueError:
count = 0
if count > 0:
findings.append({
"severity": "info" if count < 10 else "warning",
"title": f"{count} package updates beschikbaar op VM106",
"body": "Voer maintenance uit wanneer gepland: apt update && apt upgrade",
})
disk = _run_ssh(VM_106_IP, "df -h / | tail -1 | awk '{print $5}'")
if disk.get("ok"):
pct = (disk.get("stdout") or "").replace("%", "").strip()
try:
used = int(pct)
if used >= 90:
findings.append({
"severity": "critical",
"title": f"Schijf VM106 {used}% vol",
"body": "Ruimte vrijmaken op root volume.",
})
elif used >= 80:
findings.append({
"severity": "warning",
"title": f"Schijf VM106 {used}% vol",
"body": "Plan opschoning binnenkort.",
})
except ValueError:
pass
saved: list[dict[str, Any]] = []
for f in findings:
note = add_note(f["title"], f.get("body", ""), f.get("severity", "info"))
if note:
saved.append(note)
if not findings:
note = add_note(
"Geen openstaande maintenance — alles OK",
f"Scan {_iso_now()}: Proxmox, Docker en schijf binnen normen.",
"info",
)
if note:
saved.append(note)
saved.sort(key=lambda n: SEVERITY_ORDER.get(str(n.get("severity")), 9))
try:
from app.connectors.gitea_repo_sync import _record_activity
from app.middleware import log_agent_event
summary = f"{len(findings)} bevinding(en) — scan {_iso_now()[:19]}"
_record_activity(
"maintenance_scan",
f"Maintenance scan: {len(findings)} bevinding(en)",
summary + "\n" + "\n".join(f"- {f['title']}" for f in findings[:8]),
metadata={"findings_count": len(findings), "for_herman": True},
)
log_agent_event(
agent_name="sysops",
agent_type="sysops",
event_type="maintenance_scan",
title=f"Maintenance scan voltooid — {len(findings)} bevinding(en)",
body=summary,
metadata={"findings": [f.get("title") for f in findings[:10]], "for_herman": True},
channel="sysops",
)
except Exception:
pass
return {
"ok": True,
"scanned_at": _iso_now(),
"findings_count": len(findings),
"notes": saved,
}
@@ -0,0 +1,83 @@
"""Register agent outputs as project assets (shared DB helpers)."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import execute, fetch_one
def get_or_create_agent_project(agent_key: str, client_id: int | None = 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"])
title = f"Agent · {key}"
created = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, updated_at)
VALUES (%s, %s, %s, %s, %s::jsonb, NOW())
RETURNING id
""",
(
title,
client_id,
f"Automatisch project voor {key} output",
key,
json.dumps({"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: int | None = None,
source_agent: str | None = None,
created_by: str | None = None,
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}
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 id, project_id, asset_type, ref_id, title
""",
(
pid,
asset_type,
ref_id,
title[:255],
file_path,
json.dumps(payload or {}),
created_by or agent,
agent,
),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (pid,))
return dict(row) if row else None
+157 -178
View File
@@ -1,4 +1,4 @@
"""Proxmox infrastructure monitoring connector for Foodlinkk IT Ops.""" """Proxmox infrastructure monitoring — Dell R340 · Proxmox VE · VM106 services."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -15,20 +15,30 @@ from urllib.request import Request, urlopen
from app.db import execute, fetch_all, fetch_one from app.db import execute, fetch_all, fetch_one
PROXMOX_HOST = "10.4.7.14" PROXMOX_HOST = os.getenv("PROXMOX_HOST", "10.4.7.14")
PROXMOX_API_URL = f"https://{PROXMOX_HOST}:8006/api2/json" PROXMOX_API_URL = f"https://{PROXMOX_HOST}:8006/api2/json"
SSH_USER = "aissa" SSH_USER = os.getenv("PROXMOX_SSH_USER", "aissa")
SSH_PASSWORD = "Foodlinkk#2026" SSH_PASSWORD = os.getenv("PROXMOX_SSH_PASS", "Foodlinkk#2026")
VM_105_IP = "10.4.7.19" VM_106_IP = os.getenv("VM106_IP", "10.4.7.18")
VM_106_IP = "10.4.7.18" VM_106_ID = 106
VM_106_NAME = "dockervm"
SERVICE_LAYOUT: list[dict[str, Any]] = [ HARDWARE_LABEL = "Dell PowerEdge R340"
{"id": "svc-cockpit", "label": "cockpit:8600", "host": VM_106_IP, "parent": "vm106-command", "port": 8600},
{"id": "svc-tools-api", "label": "tools-api:8700", "host": VM_106_IP, "parent": "vm106-command", "port": 8700}, SERVICE_CATALOG: list[dict[str, Any]] = [
{"id": "svc-email-agent", "label": "email-agent:8801", "host": VM_106_IP, "parent": "vm106-command", "port": 8801}, {"id": "svc-cockpit", "label": "Cockpit", "port": 8600, "url": f"http://{VM_106_IP}:8600", "role": "Command Center UI"},
{"id": "svc-gitea", "label": "gitea:3001", "host": VM_105_IP, "parent": "vm105-hermes", "port": 3001}, {"id": "svc-tools-api", "label": "Tools API", "port": 8700, "url": f"http://{VM_106_IP}:8700/docs", "role": "Connectors & API"},
{"id": "svc-ollama", "label": "ollama:11434", "host": VM_105_IP, "parent": "vm105-hermes", "port": 11434}, {"id": "svc-gitea", "label": "Gitea", "port": 3001, "url": f"http://{VM_106_IP}:3001", "role": "Git · config backups"},
{"id": "svc-email-agent", "label": "Email Agent", "port": 8801, "url": f"http://{VM_106_IP}:8801", "role": "IMAP/SMTP sync"},
{"id": "svc-grafana", "label": "Grafana", "port": 3002, "url": f"http://{VM_106_IP}:3002", "role": "Metrics dashboards"},
{"id": "svc-prometheus", "label": "Prometheus", "port": 9090, "url": f"http://{VM_106_IP}:9090", "role": "Metrics scrape"},
{"id": "svc-browser-agent", "label": "Browser Agent", "port": 7790, "url": f"http://{VM_106_IP}:7790", "role": "Web automation"},
]
LAYER_AGENTS: list[dict[str, Any]] = [
{"id": "mon-sysops", "label": "SysOps", "agent_key": "sysops", "role": "Backup · Proxmox · Docker · maintenance"},
{"id": "mon-research", "label": "Research", "agent_key": "research", "role": "Markt intel (approval gate)"},
] ]
@@ -38,26 +48,16 @@ def _iso_now() -> str:
def _run_ssh(host: str, command: str, timeout: int = 12) -> dict[str, Any]: def _run_ssh(host: str, command: str, timeout: int = 12) -> dict[str, Any]:
ssh_cmd = [ ssh_cmd = [
"sshpass", "sshpass", "-p", SSH_PASSWORD, "ssh",
"-p", "-o", "StrictHostKeyChecking=no",
SSH_PASSWORD, "-o", "UserKnownHostsFile=/dev/null",
"ssh", "-o", "ConnectTimeout=7",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"ConnectTimeout=7",
f"{SSH_USER}@{host}", f"{SSH_USER}@{host}",
command, command,
] ]
try: try:
proc = subprocess.run( # noqa: S603 proc = subprocess.run( # noqa: S603
ssh_cmd, ssh_cmd, capture_output=True, text=True, timeout=timeout, check=False,
capture_output=True,
text=True,
timeout=timeout,
check=False,
) )
except FileNotFoundError as exc: except FileNotFoundError as exc:
return {"ok": False, "error": f"ssh tooling missing: {exc}"} return {"ok": False, "error": f"ssh tooling missing: {exc}"}
@@ -77,15 +77,12 @@ def _http_get_json(url: str, headers: dict[str, str] | None = None, timeout: int
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
with urlopen(req, timeout=timeout, context=ctx) as resp: # noqa: S310 with urlopen(req, timeout=timeout, context=ctx) as resp: # noqa: S310
payload = resp.read().decode("utf-8") return json.loads(resp.read().decode("utf-8"))
return json.loads(payload)
def _build_token_header(token_value: str) -> str: def _build_token_header(token_value: str) -> str:
val = token_value.strip() val = token_value.strip()
if val.startswith("PVEAPIToken="): return val if val.startswith("PVEAPIToken=") else f"PVEAPIToken={val}"
return val
return f"PVEAPIToken={val}"
def _create_api_token_via_ssh() -> str | None: def _create_api_token_via_ssh() -> str | None:
@@ -101,68 +98,62 @@ def _create_api_token_via_ssh() -> str | None:
parsed = json.loads(result.get("stdout") or "{}") parsed = json.loads(result.get("stdout") or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
tokenid = parsed.get("full-tokenid") tokenid, secret = parsed.get("full-tokenid"), parsed.get("value")
secret = parsed.get("value") return f"PVEAPIToken={tokenid}={secret}" if tokenid and secret else None
if tokenid and secret:
return f"PVEAPIToken={tokenid}={secret}"
return None
def _fetch_nodes_via_api() -> tuple[list[dict[str, Any]], str, str | None]: def _fetch_proxmox_state() -> tuple[str, float, float, str, str, str | None]:
token = os.getenv("PROXMOX_TOKEN") token = os.getenv("PROXMOX_TOKEN")
tried = [] api_source, api_error = "none", None
nodes: list[dict[str, Any]] = []
if token: if token:
tried.append("env-token")
try: try:
data = _http_get_json( data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": _build_token_header(token)})
f"{PROXMOX_API_URL}/nodes", nodes = data.get("data") or []
headers={"Authorization": _build_token_header(token)}, api_source = "api-token-env"
)
return data.get("data") or [], "api-token-env", None
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
tried.append(f"env-failed:{exc}") api_error = str(exc)
created = _create_api_token_via_ssh()
if created:
tried.append("ssh-created-token")
try:
data = _http_get_json(
f"{PROXMOX_API_URL}/nodes",
headers={"Authorization": created},
)
return data.get("data") or [], "api-token-ssh", None
except Exception as exc: # noqa: BLE001
tried.append(f"ssh-token-failed:{exc}")
return [], "none", ", ".join(tried) if tried else "no-token"
if not nodes:
created = _create_api_token_via_ssh()
if created:
try:
data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": created})
nodes = data.get("data") or []
api_source = "api-token-ssh"
except Exception as exc: # noqa: BLE001
api_error = str(exc)
def _fetch_nodes_via_ssh() -> tuple[list[dict[str, Any]], str, str | None]: if not nodes:
pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json") pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json")
if pvesh.get("ok"): if pvesh.get("ok"):
try: try:
return json.loads(pvesh["stdout"] or "[]"), "ssh-pvesh", None nodes = json.loads(pvesh["stdout"] or "[]")
except json.JSONDecodeError: api_source = "ssh-pvesh"
pass except json.JSONDecodeError:
qm = _run_ssh(PROXMOX_HOST, "qm list") api_error = pvesh.get("stderr") or "pvesh parse error"
rows: list[dict[str, Any]] = []
api_node = next((n for n in nodes if isinstance(n, dict) and (n.get("node") or "").strip()), None)
cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
mem = float(api_node.get("mem", 0)) if api_node else 0.0
status = str(api_node.get("status") or "unknown") if api_node else "unknown"
vm_status = "unknown"
qm = _run_ssh(PROXMOX_HOST, f"qm status {VM_106_ID} 2>/dev/null || qm list | grep '^{VM_106_ID} '")
if qm.get("ok") and qm.get("stdout"): if qm.get("ok") and qm.get("stdout"):
lines = (qm["stdout"] or "").splitlines() line = qm["stdout"].splitlines()[0].lower()
for line in lines[1:]: if "running" in line:
parts = line.split() vm_status = "running"
if not parts: elif "stopped" in line:
continue vm_status = "stopped"
vmid = parts[0] else:
rows.append( vm_status = "online" if _port_health(VM_106_IP, 22) else "offline"
{
"node": "pve", if status == "unknown" and _port_health(PROXMOX_HOST, 8006):
"type": "qemu", status = "online"
"id": f"qemu/{vmid}",
"vmid": int(vmid) if vmid.isdigit() else vmid, return status, cpu, mem, vm_status, api_source, api_error
"status": parts[2] if len(parts) > 2 else "unknown",
}
)
return rows, "ssh-qm-list", None
err = pvesh.get("stderr") or qm.get("stderr") or "ssh lookup failed"
return [], "none", err
def _port_health(host: str, port: int, timeout: float = 1.5) -> bool: def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
@@ -176,13 +167,10 @@ def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
def check_docker_services() -> dict[str, Any]: def check_docker_services() -> dict[str, Any]:
result = _run_ssh(VM_106_IP, "docker ps --format json") result = _run_ssh(VM_106_IP, "docker ps --format json")
method = "docker-ps-json"
if not result.get("ok"): if not result.get("ok"):
result = _run_ssh(VM_106_IP, "docker ps --format '{{json .}}'") result = _run_ssh(VM_106_IP, "docker ps --format '{{json .}}'")
method = "docker-ps-template-json"
if not result.get("ok"): if not result.get("ok"):
return {"ok": False, "source": method, "error": result.get("stderr") or "docker check failed", "containers": []} return {"ok": False, "error": result.get("stderr") or "docker check failed", "containers": []}
containers: list[dict[str, Any]] = [] containers: list[dict[str, Any]] = []
for line in (result.get("stdout") or "").splitlines(): for line in (result.get("stdout") or "").splitlines():
line = line.strip() line = line.strip()
@@ -193,102 +181,99 @@ def check_docker_services() -> dict[str, Any]:
containers.append(parsed if isinstance(parsed, dict) else {"raw": parsed}) containers.append(parsed if isinstance(parsed, dict) else {"raw": parsed})
except json.JSONDecodeError: except json.JSONDecodeError:
containers.append({"raw": line}) containers.append({"raw": line})
return {"ok": True, "source": method, "containers": containers} return {"ok": True, "containers": containers}
def _online_services() -> list[dict[str, Any]]:
docker_state = check_docker_services()
docker_names = {
str(c.get("Names") or c.get("Name") or "").lower(): c
for c in docker_state.get("containers", [])
}
online: list[dict[str, Any]] = []
for svc in SERVICE_CATALOG:
up = _port_health(VM_106_IP, int(svc["port"]))
if not up:
continue
hint = "running"
for name, details in docker_names.items():
slug = svc["id"].replace("svc-", "").replace("-", "")
if slug in name.replace("-", ""):
hint = str(details.get("State") or details.get("Status") or "running")
break
online.append({
"id": svc["id"],
"label": f"{svc['label']}:{svc['port']}",
"type": "service",
"status": "online",
"host": VM_106_IP,
"port": svc["port"],
"url": svc["url"],
"role": svc["role"],
"hint": hint,
"agent": "sysops",
"children": [],
})
return online
def get_topology() -> dict[str, Any]: def get_topology() -> dict[str, Any]:
api_nodes, api_source, api_error = _fetch_nodes_via_api() host_status, host_cpu, host_mem, vm_status, api_source, api_error = _fetch_proxmox_state()
ssh_nodes: list[dict[str, Any]] = [] services = _online_services()
ssh_source = "none" proxmox_reachable = _port_health(PROXMOX_HOST, 8006)
ssh_error: str | None = None
if not api_nodes:
ssh_nodes, ssh_source, ssh_error = _fetch_nodes_via_ssh()
api_node = next((n for n in api_nodes if (n.get("node") or "").strip()), None) if api_nodes else None
host_cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
host_mem = float(api_node.get("mem", 0)) if api_node else 0.0
host_status = api_node.get("status") if api_node else "unknown"
if host_status == "unknown" and ssh_nodes:
host_status = "online"
vm_states: dict[str, str] = {"105": "unknown", "106": "unknown"}
source_rows = api_nodes or ssh_nodes
for row in source_rows:
vmid = str(row.get("vmid") or "").strip()
if vmid in vm_states:
vm_states[vmid] = str(row.get("status") or "unknown")
docker_state = check_docker_services()
docker_names = {
str(c.get("Names") or c.get("Names.0") or c.get("Name") or "").lower(): c for c in docker_state.get("containers", [])
}
vm105_children: list[dict[str, Any]] = []
vm106_children: list[dict[str, Any]] = []
for svc in SERVICE_LAYOUT:
up = _port_health(str(svc["host"]), int(svc["port"]))
hinted = "unknown"
for name, details in docker_names.items():
if svc["label"].split(":")[0].replace("-", "") in name.replace("-", ""):
hinted = str(details.get("State") or details.get("Status") or "running")
break
item = {
"id": svc["id"],
"label": svc["label"],
"type": "service",
"status": "online" if up else "offline",
"cpu": None,
"mem": None,
"host": svc["host"],
"hint": hinted,
"children": [],
}
if svc["parent"] == "vm105-hermes":
vm105_children.append(item)
else:
vm106_children.append(item)
topology_nodes = [ topology_nodes = [
{ {
"id": "proxmox-host", "id": "hardware-r340",
"label": f"proxmox-host ({PROXMOX_HOST})", "label": HARDWARE_LABEL,
"type": "proxmox", "type": "hardware",
"status": host_status, "status": "online" if proxmox_reachable or _port_health(VM_106_IP, 8600) else "degraded",
"cpu": host_cpu, "role": "Bare metal host",
"mem": host_mem,
"children": [ "children": [
{ {
"id": "vm105-hermes", "id": "proxmox-host",
"label": f"vm105-hermes ({VM_105_IP})", "label": f"Proxmox VE · {PROXMOX_HOST}",
"type": "vm", "type": "proxmox",
"status": vm_states["105"], "status": host_status if proxmox_reachable else ("online" if _port_health(PROXMOX_HOST, 22) else "offline"),
"cpu": None, "cpu": host_cpu,
"mem": None, "mem": host_mem,
"children": vm105_children, "role": "Hypervisor · node pve",
}, "url": f"https://{PROXMOX_HOST}:8006",
{ "children": [
"id": "vm106-command", {
"label": f"vm106-command ({VM_106_IP})", "id": "vm106-command",
"type": "vm", "label": f"VM {VM_106_ID} · {VM_106_NAME} · {VM_106_IP}",
"status": vm_states["106"], "type": "vm",
"cpu": None, "status": vm_status if vm_status != "unknown" else ("online" if services else "offline"),
"mem": None, "vmid": VM_106_ID,
"children": vm106_children, "role": "Foodlinkk Command Center",
}, "children": services,
}
],
}
], ],
} }
] ]
return { return {
"generated_at": _iso_now(), "generated_at": _iso_now(),
"nodes": topology_nodes, "nodes": topology_nodes,
"layer_agents": LAYER_AGENTS,
"layers": [
{"id": "L0", "label": "Hardware", "y": 55},
{"id": "L1", "label": "Hypervisor", "y": 175},
{"id": "L2", "label": f"VM {VM_106_ID}", "y": 295},
{"id": "L3", "label": "Online services", "y": 415},
{"id": "L4", "label": "IT Agents", "y": 555},
],
"meta": { "meta": {
"hardware": HARDWARE_LABEL,
"proxmox_host": PROXMOX_HOST, "proxmox_host": PROXMOX_HOST,
"vm106_ip": VM_106_IP,
"vm106_id": VM_106_ID,
"services_online": len(services),
"api_source": api_source, "api_source": api_source,
"api_error": api_error, "api_error": api_error,
"ssh_source": ssh_source,
"ssh_error": ssh_error,
"docker_source": docker_state.get("source"),
"docker_ok": docker_state.get("ok", False),
}, },
} }
@@ -322,10 +307,8 @@ def _table_exists(table_name: str) -> bool:
row = fetch_one( row = fetch_one(
""" """
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1 FROM information_schema.tables
FROM information_schema.tables WHERE table_schema = 'public' AND table_name = %s
WHERE table_schema = 'public'
AND table_name = %s
) AS ok ) AS ok
""", """,
(table_name,), (table_name,),
@@ -340,10 +323,8 @@ def poll_and_snapshot() -> dict[str, Any]:
cols = fetch_all( cols = fetch_all(
""" """
SELECT column_name SELECT column_name FROM information_schema.columns
FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'infra_snapshots'
WHERE table_schema = 'public'
AND table_name = 'infra_snapshots'
ORDER BY ordinal_position ORDER BY ordinal_position
""" """
) )
@@ -354,7 +335,6 @@ def poll_and_snapshot() -> dict[str, Any]:
"summary": {k: v for k, v in status.items() if k != "topology"}, "summary": {k: v for k, v in status.items() if k != "topology"},
"generated_at": status.get("generated_at"), "generated_at": status.get("generated_at"),
} }
value_map: dict[str, Any] = {} value_map: dict[str, Any] = {}
if "source" in colset: if "source" in colset:
value_map["source"] = "proxmox" value_map["source"] = "proxmox"
@@ -375,7 +355,6 @@ def poll_and_snapshot() -> dict[str, Any]:
return {"ok": False, "saved": False, "reason": "infra_snapshots has no compatible columns", "status": status} return {"ok": False, "saved": False, "reason": "infra_snapshots has no compatible columns", "status": status}
columns = list(value_map.keys()) columns = list(value_map.keys())
placeholders = ", ".join(["%s"] * len(columns)) sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({', '.join([ '%s' ] * len(columns))})"
sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({placeholders})"
execute(sql, tuple(value_map[c] for c in columns)) execute(sql, tuple(value_map[c] for c in columns))
return {"ok": True, "saved": True, "columns": columns, "status": status} return {"ok": True, "saved": True, "columns": columns, "status": status}
+51
View File
@@ -1,12 +1,24 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.connectors.config_backup import list_backups, prepare_backup_manifest
from app.connectors.gitea_repo_sync import list_activity, run_gitea_repo_sync
from app.connectors.ops_maintenance import list_notes, run_maintenance_scan
from app.connectors.proxmox import get_status_summary, get_topology, poll_and_snapshot from app.connectors.proxmox import get_status_summary, get_topology, poll_and_snapshot
router = APIRouter(prefix="/ops", tags=["ops"]) router = APIRouter(prefix="/ops", tags=["ops"])
class BackupRunBody(BaseModel):
approval_request_id: int | None = None
class GiteaSyncBody(BaseModel):
reason: str = Field(default="manual", max_length=120)
@router.get("/status") @router.get("/status")
def ops_status() -> dict: def ops_status() -> dict:
try: try:
@@ -29,3 +41,42 @@ def ops_refresh() -> dict:
return poll_and_snapshot() return poll_and_snapshot()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"ops refresh failed: {exc}") from exc raise HTTPException(status_code=500, detail=f"ops refresh failed: {exc}") from exc
@router.get("/backups")
def ops_backups(limit: int = 10) -> dict:
return {"items": list_backups(limit), "manifest": prepare_backup_manifest()}
@router.post("/backup/run")
def ops_backup_run(body: BackupRunBody) -> dict:
try:
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=body.approval_request_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/gitea/sync")
def ops_gitea_sync(body: GiteaSyncBody) -> dict:
try:
return run_gitea_repo_sync(reason=body.reason)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/sysops-activity")
def ops_sysops_activity(limit: int = 30) -> dict:
return {"items": list_activity(limit), "count": len(list_activity(limit))}
@router.get("/maintenance")
def ops_maintenance(limit: int = 15) -> dict:
return {"items": list_notes(limit, unresolved_only=True)}
@router.post("/maintenance/scan")
def ops_maintenance_scan() -> dict:
try:
return run_maintenance_scan()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
+331 -132
View File
@@ -1,4 +1,4 @@
"""SVG packaging generator for Foodlinkk.""" """SVG packaging generator for Foodlinkk — rich design options."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -9,8 +9,7 @@ import svgwrite
from barcode import Code128 from barcode import Code128
from barcode.writer import SVGWriter from barcode.writer import SVGWriter
MM_TO_PX = 3.7795275591
MM_TO_PX = 3.7795275591 # 96 DPI conversion
DEFAULT_BARCODE_VALUE = "8710000000012" DEFAULT_BARCODE_VALUE = "8710000000012"
FOODLINKK_BRAND = { FOODLINKK_BRAND = {
@@ -18,11 +17,24 @@ FOODLINKK_BRAND = {
"panel": "#101a2d", "panel": "#101a2d",
"primary": "#00e5ff", "primary": "#00e5ff",
"secondary": "#ffd700", "secondary": "#ffd700",
"accent": "#b8ff3c",
"text": "#e2e8f0", "text": "#e2e8f0",
"muted": "#94a3b8",
"cut_line": "#ef4444", "cut_line": "#ef4444",
"fold_line": "#60a5fa", "fold_line": "#60a5fa",
"bleed": "#f97316",
"halal": "#22c55e",
} }
PACKAGING_TYPES = (
"folding_box",
"wrap",
"round_label",
"sleeve",
"pouch",
"tray",
)
def _mm(mm: float) -> float: def _mm(mm: float) -> float:
return round(float(mm) * MM_TO_PX, 2) return round(float(mm) * MM_TO_PX, 2)
@@ -44,32 +56,227 @@ def _barcode_data_uri(value: str) -> str:
return f"data:image/svg+xml;base64,{encoded}" return f"data:image/svg+xml;base64,{encoded}"
def _qr_placeholder_svg(size: int = 120) -> str:
"""Simple QR-style grid when qrcode lib unavailable."""
cell = max(size // 10, 8)
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">',
f'<rect width="{size}" height="{size}" fill="#fff"/>',
]
for y in range(0, size, cell):
for x in range(0, size, cell):
if (x + y) % (cell * 2) == 0 or x < cell * 3 and y < cell * 3 or x > size - cell * 4 and y < cell * 3:
parts.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="#111"/>')
parts.append("</svg>")
raw = "".join(parts).encode("utf-8")
return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
def _spec_text(spec: dict[str, Any]) -> dict[str, str]:
text = spec.get("text") or {}
if not isinstance(text, dict):
text = {}
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
return {
"product_name": str(text.get("product_name") or brand.get("product_name") or "FOODLINKK"),
"tagline": str(text.get("tagline") or brand.get("tagline") or "Premium food solutions"),
"subtitle": str(text.get("subtitle") or text.get("weight") or ""),
"ingredients": str(text.get("ingredients") or ""),
"best_before": str(text.get("best_before") or "Ten minste houdbaar tot: zie verpakking"),
"origin": str(text.get("origin") or "Geproduceerd in NL"),
}
def _nutrition_rows(spec: dict[str, Any]) -> list[tuple[str, str]]:
text = spec.get("text") or {}
rows = text.get("nutrition") if isinstance(text, dict) else None
if isinstance(rows, list) and rows:
out: list[tuple[str, str]] = []
for row in rows[:12]:
if isinstance(row, dict):
out.append((str(row.get("k") or row.get("label") or ""), str(row.get("v") or row.get("value") or "")))
return out
return [
("Energie", "450 kJ / 107 kcal"),
("Vetten", "4.2 g"),
("waarvan verzadigd", "1.1 g"),
("Koolhydraten", "12 g"),
("waarvan suikers", "2.8 g"),
("Eiwitten", "6.5 g"),
("Zout", "0.85 g"),
]
def _draw_bleed(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], bleed_mm: float) -> None:
if bleed_mm <= 0:
return
b = _mm(bleed_mm)
dwg.add(
dwg.rect(
insert=(x - b, y - b),
size=(w + 2 * b, h + 2 * b),
fill="none",
stroke=brand["bleed"],
stroke_dasharray="6,4",
stroke_width=1.0,
stroke_opacity=0.7,
)
)
def _draw_logo_block(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], txt: dict[str, str]) -> None:
dwg.add(
dwg.rect(insert=(x, y), size=(w, h), rx=10, ry=10, fill=brand["panel"], stroke=brand["secondary"], stroke_width=2)
)
dwg.add(
dwg.text(
txt["product_name"][:42],
insert=(x + 14, y + h * 0.42),
fill=brand["text"],
font_size=min(22, max(12, w * 0.045)),
font_family="Arial, Helvetica, sans-serif",
font_weight="bold",
)
)
if txt["tagline"]:
dwg.add(
dwg.text(
txt["tagline"][:60],
insert=(x + 14, y + h * 0.62),
fill=brand["primary"],
font_size=min(14, max(9, w * 0.028)),
font_family="Arial, Helvetica, sans-serif",
)
)
if txt["subtitle"]:
dwg.add(
dwg.text(
txt["subtitle"][:24],
insert=(x + 14, y + h * 0.82),
fill=brand["muted"],
font_size=11,
font_family="Arial, Helvetica, sans-serif",
)
)
def _draw_nutrition_panel(
dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], rows: list[tuple[str, str]]
) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#ffffff", stroke=brand["text"], stroke_width=1.2, rx=4))
dwg.add(
dwg.text(
"Voedingswaarden per 100g",
insert=(x + 8, y + 16),
fill="#111827",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
line_y = y + 26
for label, value in rows:
dwg.add(dwg.text(label[:28], insert=(x + 8, line_y), fill="#374151", font_size=9, font_family="Arial, sans-serif"))
dwg.add(dwg.text(value[:16], insert=(x + w - 8, line_y), fill="#111827", font_size=9, font_family="Arial, sans-serif", text_anchor="end"))
line_y += 13
if line_y > y + h - 6:
break
def _draw_ingredients(dwg: svgwrite.Drawing, x: float, y: float, w: float, text: str, brand: dict[str, str]) -> None:
dwg.add(
dwg.text(
"Ingrediënten:",
insert=(x, y),
fill=brand["text"],
font_size=10,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
chunk = text[:220] or "Ingrediënten volgens recept — vul aan in Packaging Studio."
words, line, line_y, line_h = chunk.split(), "", y + 14, 12
for word in words:
test = (line + " " + word).strip()
if len(test) > 42:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
line, line_y = word, line_y + line_h
else:
line = test
if line:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
def _draw_halal_badge(dwg: svgwrite.Drawing, x: float, y: float, brand: dict[str, str]) -> None:
r = 28
dwg.add(dwg.circle(center=(x + r, y + r), r=r, fill=brand["halal"], stroke="#ffffff", stroke_width=2))
dwg.add(
dwg.text(
"HALAL",
insert=(x + r, y + r + 5),
fill="#052e16",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
text_anchor="middle",
)
)
def _draw_window(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#bae6fd", fill_opacity=0.35, stroke="#38bdf8", stroke_width=1.5, rx=6))
dwg.add(dwg.text("WINDOW", insert=(x + w / 2, y + h / 2 + 4), fill="#0c4a6e", font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
def _draw_dimensions(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, spec: dict[str, Any], brand: dict[str, str]) -> None:
label = f"{spec.get('width_mm')} × {spec.get('height_mm')} × {spec.get('depth_mm')} mm"
dwg.add(
dwg.text(
label,
insert=(x + w / 2, y + h + 18),
fill=brand["muted"],
font_size=10,
text_anchor="middle",
font_family="Arial, sans-serif",
)
)
def _canvas_size(ptype: str, width_mm: float, height_mm: float, depth_mm: float) -> tuple[float, float]:
if ptype == "folding_box":
return _mm((width_mm * 2) + (depth_mm * 2) + 20), _mm(height_mm + depth_mm + 20)
if ptype == "wrap":
return _mm(width_mm + 20), _mm(height_mm + 20)
if ptype == "round_label":
d = max(min(width_mm, height_mm), 20)
return _mm(d + 20), _mm(d + 20)
if ptype == "sleeve":
return _mm((width_mm * 2) + depth_mm + 20), _mm(height_mm + 20)
if ptype == "pouch":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
if ptype == "tray":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
raise ValueError(f"Unsupported packaging type: {ptype}")
def generate_packaging(spec: dict[str, Any]) -> str: def generate_packaging(spec: dict[str, Any]) -> str:
"""Create an SVG packaging design based on a simple spec.""" """Create an SVG packaging design from a rich spec."""
ptype = (spec.get("type") or "folding_box").strip().lower() ptype = (spec.get("type") or "folding_box").strip().lower()
if ptype not in PACKAGING_TYPES:
raise ValueError(f"Unsupported packaging type: {ptype}")
width_mm = float(spec.get("width_mm", 120)) width_mm = float(spec.get("width_mm", 120))
height_mm = float(spec.get("height_mm", 80)) height_mm = float(spec.get("height_mm", 80))
depth_mm = float(spec.get("depth_mm", 40)) depth_mm = float(spec.get("depth_mm", 40))
bleed_mm = float(spec.get("bleed_mm", 3))
elements = spec.get("elements", {}) elements = spec.get("elements", {})
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})} brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
txt = _spec_text(spec)
if ptype == "folding_box": canvas_w, canvas_h = _canvas_size(ptype, width_mm, height_mm, depth_mm)
canvas_w = _mm((width_mm * 2) + (depth_mm * 2) + 20)
canvas_h = _mm(height_mm + depth_mm + 20)
elif ptype == "wrap":
canvas_w = _mm(width_mm + 20)
canvas_h = _mm(height_mm + 20)
elif ptype == "round_label":
diameter = max(min(width_mm, height_mm), 20)
canvas_w = _mm(diameter + 20)
canvas_h = _mm(diameter + 20)
else:
raise ValueError(f"Unsupported packaging type: {ptype}")
dwg = svgwrite.Drawing(size=(canvas_w, canvas_h)) dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
dwg.viewbox(0, 0, canvas_w, canvas_h) dwg.viewbox(0, 0, canvas_w, canvas_h)
# Background and frame
dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"])) dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"]))
dwg.add( dwg.add(
dwg.rect( dwg.rect(
@@ -85,141 +292,114 @@ def generate_packaging(spec: dict[str, Any]) -> str:
) )
margin = 24 margin = 24
body_w = body_h = x0 = y0 = 0.0
if ptype == "folding_box": if ptype == "folding_box":
body_w = _mm(width_mm) body_w = _mm(width_mm)
body_h = _mm(height_mm) body_h = _mm(height_mm)
depth_w = _mm(depth_mm) depth_w = _mm(depth_mm)
x0 = margin x0, y0 = margin, margin
y0 = margin
panels = [depth_w, body_w, depth_w, body_w] panels = [depth_w, body_w, depth_w, body_w]
x = x0 x = x0
for idx, panel_w in enumerate(panels): for idx, panel_w in enumerate(panels):
dwg.add( fill = brand["primary"] if idx % 2 else brand["secondary"]
dwg.rect( dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=fill, stroke_opacity=0.45, stroke_width=1.6))
insert=(x, y0),
size=(panel_w, body_h),
fill="none",
stroke=brand["primary"] if idx % 2 else brand["secondary"],
stroke_opacity=0.45,
stroke_width=1.6,
)
)
x += panel_w x += panel_w
if _elements_enabled(elements, "fold_lines"): if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0] x = x0 + panels[0]
for panel_w in panels[1:]: for panel_w in panels[1:]:
dwg.add( dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
dwg.line(
start=(x, y0),
end=(x, y0 + body_h),
stroke=brand["fold_line"],
stroke_dasharray="8,6",
stroke_width=1.2,
)
)
x += panel_w x += panel_w
if _elements_enabled(elements, "cut_lines"): if _elements_enabled(elements, "cut_lines"):
dwg.add( dwg.add(dwg.rect(insert=(x0, y0), size=(sum(panels), body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="5,4", stroke_width=1.1))
dwg.rect( if _elements_enabled(elements, "glue_tabs"):
insert=(x0, y0), tab_w, tab_h = _mm(12), _mm(8)
size=(sum(panels), body_h), dwg.add(dwg.rect(insert=(x0 - tab_w, y0 + body_h * 0.4), size=(tab_w, tab_h), fill=brand["accent"], fill_opacity=0.35, stroke=brand["accent"]))
fill="none", logo_x = x0 + panels[0] + (_mm(width_mm) * 0.1)
stroke=brand["cut_line"], logo_y = y0 + (_mm(height_mm) * 0.12)
stroke_dasharray="5,4", logo_w = _mm(width_mm) * 0.8
stroke_width=1.1, logo_h = _mm(height_mm) * 0.38
)
)
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.12)
logo_y = y0 + (_mm(height_mm) * 0.16)
logo_w = _mm(width_mm) * 0.76
logo_h = _mm(height_mm) * 0.42
elif ptype == "wrap": elif ptype == "wrap":
body_w, body_h = _mm(width_mm), _mm(height_mm)
x0, y0 = margin, margin
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.2))
if _elements_enabled(elements, "fold_lines"):
dwg.add(dwg.line(start=(x0 + body_w / 2, y0), end=(x0 + body_w / 2, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
if _elements_enabled(elements, "cut_lines"):
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="6,4", stroke_width=1.1))
logo_x, logo_y = x0 + body_w * 0.1, y0 + body_h * 0.12
logo_w, logo_h = body_w * 0.8, body_h * 0.35
elif ptype == "sleeve":
body_w, body_h = _mm(width_mm), _mm(height_mm)
depth_w = _mm(depth_mm)
x0, y0 = margin, margin
panels = [body_w, depth_w, body_w]
x = x0
for panel_w in panels:
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=brand["primary"], stroke_width=1.8))
x += panel_w
if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0]
for panel_w in panels[1:]:
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
x += panel_w
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.15
logo_w, logo_h = body_w * 0.76, body_h * 0.4
elif ptype == "pouch":
body_w = _mm(width_mm) body_w = _mm(width_mm)
body_h = _mm(height_mm) body_h = _mm(height_mm)
x0 = margin seal_h = _mm(max(depth_mm, 15))
y0 = margin x0, y0 = margin, margin + seal_h
dwg.add( dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2))
dwg.rect( dwg.add(dwg.rect(insert=(x0, y0 - seal_h), size=(body_w, seal_h), fill=brand["secondary"], fill_opacity=0.2, stroke=brand["secondary"]))
insert=(x0, y0), dwg.add(dwg.text("SEAL", insert=(x0 + body_w / 2, y0 - seal_h / 2 + 4), fill=brand["text"], font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
size=(body_w, body_h), logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.18
fill="none", logo_w, logo_h = body_w * 0.76, body_h * 0.42
stroke=brand["primary"],
stroke_width=2.2, elif ptype == "tray":
) body_w, body_h = _mm(width_mm), _mm(height_mm)
) lip = _mm(max(depth_mm, 8))
if _elements_enabled(elements, "cut_lines"): x0, y0 = margin, margin
dwg.add( dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.4))
dwg.rect( dwg.add(dwg.rect(insert=(x0 + lip, y0 + lip), size=(body_w - 2 * lip, body_h - 2 * lip), fill="none", stroke=brand["fold_line"], stroke_dasharray="5,4", stroke_width=1))
insert=(x0, y0), logo_x, logo_y = x0 + lip + 8, y0 + lip + 8
size=(body_w, body_h), logo_w, logo_h = body_w - 2 * lip - 16, (body_h - 2 * lip) * 0.45
fill="none",
stroke=brand["cut_line"], else: # round_label
stroke_dasharray="6,4",
stroke_width=1.1,
)
)
logo_x = x0 + (body_w * 0.14)
logo_y = y0 + (body_h * 0.14)
logo_w = body_w * 0.72
logo_h = body_h * 0.36
else:
diameter = min(canvas_w, canvas_h) - (margin * 2) diameter = min(canvas_w, canvas_h) - (margin * 2)
cx = canvas_w / 2 cx, cy = canvas_w / 2, canvas_h / 2
cy = canvas_h / 2 dwg.add(dwg.circle(center=(cx, cy), r=diameter / 2, fill="none", stroke=brand["primary"], stroke_width=2.4))
dwg.add(
dwg.circle(
center=(cx, cy),
r=diameter / 2,
fill="none",
stroke=brand["primary"],
stroke_width=2.4,
)
)
if _elements_enabled(elements, "cut_lines"): if _elements_enabled(elements, "cut_lines"):
dwg.add( dwg.add(dwg.circle(center=(cx, cy), r=(diameter / 2) - 4, fill="none", stroke=brand["cut_line"], stroke_dasharray="4,4", stroke_width=1.0))
dwg.circle( logo_w, logo_h = diameter * 0.64, diameter * 0.22
center=(cx, cy), logo_x, logo_y = cx - (logo_w / 2), cy - (logo_h / 2) - 8
r=(diameter / 2) - 4, body_w = body_h = diameter
fill="none", x0, y0 = cx - (diameter / 2), cy - (diameter / 2)
stroke=brand["cut_line"],
stroke_dasharray="4,4", if _elements_enabled(elements, "bleed"):
stroke_width=1.0, _draw_bleed(dwg, x0, y0, body_w if ptype != "folding_box" else sum([_mm(depth_mm), _mm(width_mm), _mm(depth_mm), _mm(width_mm)]), body_h, brand, bleed_mm)
)
)
logo_w = diameter * 0.64
logo_h = diameter * 0.22
logo_x = cx - (logo_w / 2)
logo_y = cy - (logo_h / 2) - 8
body_w = diameter
body_h = diameter
x0 = cx - (diameter / 2)
y0 = cy - (diameter / 2)
if _elements_enabled(elements, "logo_area"): if _elements_enabled(elements, "logo_area"):
dwg.add( _draw_logo_block(dwg, logo_x, logo_y, logo_w, logo_h, brand, txt)
dwg.rect(
insert=(logo_x, logo_y), if _elements_enabled(elements, "window"):
size=(logo_w, logo_h), wx = x0 + body_w * 0.55 if ptype != "round_label" else logo_x + logo_w * 0.1
rx=8, wy = y0 + body_h * 0.55 if ptype != "round_label" else logo_y + logo_h + 8
ry=8, _draw_window(dwg, wx, wy, max(60, body_w * 0.32), max(40, body_h * 0.22))
fill="none",
stroke=brand["secondary"], if _elements_enabled(elements, "halal_badge"):
stroke_width=2, _draw_halal_badge(dwg, x0 + 8, y0 + 8, brand)
)
) if _elements_enabled(elements, "nutrition_panel"):
dwg.add( nx = x0 + 8
dwg.text( ny = y0 + body_h - min(120, body_h * 0.45)
"FOODLINKK", _draw_nutrition_panel(dwg, nx, ny, min(150, body_w * 0.42), min(110, body_h * 0.4), brand, _nutrition_rows(spec))
insert=(logo_x + 12, logo_y + (logo_h / 2) + 5),
fill=brand["text"], if _elements_enabled(elements, "ingredients") and txt["ingredients"]:
font_size=18, _draw_ingredients(dwg, x0 + 8, y0 + body_h - 28, body_w - 16, txt["ingredients"], brand)
font_family="Arial, sans-serif",
font_weight="bold",
)
)
if _elements_enabled(elements, "barcode"): if _elements_enabled(elements, "barcode"):
barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE)) barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE))
@@ -230,4 +410,23 @@ def generate_packaging(spec: dict[str, Any]) -> str:
dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff")) dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff"))
dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h))) dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h)))
if _elements_enabled(elements, "qr_code"):
qr_uri = _qr_placeholder_svg(100)
qr_s = min(90, body_w * 0.22)
dwg.add(dwg.image(href=qr_uri, insert=(x0 + 10, y0 + body_h - qr_s - 10), size=(qr_s, qr_s)))
if _elements_enabled(elements, "dimensions"):
_draw_dimensions(dwg, x0, y0, body_w, body_h, spec, brand)
design_name = spec.get("design_name") or txt["product_name"]
dwg.add(
dwg.text(
f"Foodlinkk Packaging Studio · {design_name}"[:80],
insert=(12, canvas_h - 10),
fill=brand["muted"],
font_size=9,
font_family="Arial, sans-serif",
)
)
return dwg.tostring() return dwg.tostring()
+252 -26
View File
@@ -1,7 +1,8 @@
"""Packaging generation API routes.""" """Packaging generation API routes — persisted in PostgreSQL."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime import json
from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
@@ -9,58 +10,229 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes
from app.packaging.generator import FOODLINKK_BRAND, generate_packaging from app.packaging.generator import FOODLINKK_BRAND, PACKAGING_TYPES, generate_packaging
router = APIRouter(prefix="/packaging", tags=["packaging"]) router = APIRouter(prefix="/packaging", tags=["packaging"])
_PROJECTS: dict[str, dict[str, Any]] = {}
class PackagingSpec(BaseModel): class PackagingSpec(BaseModel):
type: Literal["folding_box", "wrap", "round_label"] type: Literal["folding_box", "wrap", "round_label", "sleeve", "pouch", "tray"] = "folding_box"
width_mm: float = Field(default=120, gt=0, le=4000) width_mm: float = Field(default=120, gt=0, le=4000)
height_mm: float = Field(default=80, gt=0, le=4000) height_mm: float = Field(default=80, gt=0, le=4000)
depth_mm: float = Field(default=40, ge=0, le=4000) depth_mm: float = Field(default=40, ge=0, le=4000)
bleed_mm: float = Field(default=3, ge=0, le=20)
elements: dict[str, bool] = Field(default_factory=dict) elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = Field(default_factory=dict) brand: dict[str, str] = Field(default_factory=dict)
text: dict[str, Any] = Field(default_factory=dict)
barcode_value: str | None = Field(default=None, max_length=64) barcode_value: str | None = Field(default=None, max_length=64)
qr_value: str | None = Field(default=None, max_length=512)
design_name: str | None = Field(default=None, max_length=120)
project_id: int | None = None
project_name: str | None = None
client_id: int | None = None
created_by: str = "ceo"
class PackagingUpdate(BaseModel):
spec: PackagingSpec
class CopyToProjectBody(BaseModel):
target_project_id: int = Field(..., gt=0)
created_by: str = "ceo"
class PackagingEmailBody(BaseModel):
to: list[str] = Field(..., min_length=1)
subject: str = Field(..., min_length=1, max_length=255)
body: str = ""
cc: list[str] = Field(default_factory=list)
attach_formats: list[str] = Field(default_factory=lambda: ["pdf"])
client_id: int | None = None
def _normalize_spec(spec_data: dict[str, Any]) -> dict[str, Any]:
if not spec_data.get("brand"):
spec_data["brand"] = dict(FOODLINKK_BRAND)
if not spec_data.get("elements"):
spec_data["elements"] = {
"barcode": True,
"logo_area": True,
"fold_lines": True,
"cut_lines": True,
"nutrition_panel": False,
"ingredients": False,
"halal_badge": False,
"window": False,
"qr_code": False,
"glue_tabs": False,
"bleed": True,
"dimensions": True,
}
return spec_data
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
spec = out.get("spec") or {}
if isinstance(spec, str):
try:
spec = json.loads(spec)
except Exception:
spec = {}
out["spec"] = spec
for key in ("created_at",):
if out.get(key) is not None and hasattr(out[key], "isoformat"):
out[key] = out[key].isoformat()
return out
def _get_project(project_id: str) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT po.*, cp.name AS project_name, cp.nas_path, c.name AS client_name, c.id AS client_id
FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
WHERE po.id = %s
""",
(project_id,),
)
return _serialize_row(dict(row)) if row else None
def _link_asset(cockpit_project_id: int, packaging_id: str, spec_data: dict[str, Any], created_by: str, file_path: str | None = None) -> None:
title = spec_data.get("design_name") or spec_data.get("text", {}).get("product_name") or f"Packaging {spec_data.get('type')}"
existing = fetch_one(
"SELECT id FROM project_assets WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s LIMIT 1",
(cockpit_project_id, packaging_id),
)
payload = json.dumps({"packaging_id": packaging_id, "spec": spec_data})
if existing:
execute(
"""
UPDATE project_assets SET title = %s, payload = %s::jsonb, file_path = COALESCE(%s, file_path)
WHERE id = %s
""",
(title, payload, file_path, existing["id"]),
)
else:
fetch_one(
"""
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
VALUES (%s, 'packaging', %s, %s, %s, %s::jsonb, %s, 'packaging')
RETURNING id
""",
(cockpit_project_id, packaging_id, title, file_path, payload, created_by),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (cockpit_project_id,))
def _resolve_cockpit_project(spec: PackagingSpec) -> int | None:
cockpit_project_id = spec.project_id
if spec.project_name and not cockpit_project_id:
row = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, project_type, updated_at)
VALUES (%s, %s, %s, %s, 'packaging', NOW())
RETURNING id
""",
(spec.project_name.strip(), spec.client_id, "Packaging project", spec.created_by),
)
cockpit_project_id = int(row["id"]) if row else None
return cockpit_project_id
def _persist_packaging(spec: PackagingSpec, packaging_id: str | None = None) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
svg = generate_packaging(spec_data)
pid = packaging_id or uuid4().hex
cockpit_project_id = _resolve_cockpit_project(spec)
existing = _get_project(pid) if packaging_id else None
if existing:
fetch_one(
"""
UPDATE packaging_outputs SET project_id = %s, spec = %s::jsonb, svg = %s, created_by = %s
WHERE id = %s RETURNING id
""",
(cockpit_project_id, json.dumps(spec_data), svg, spec.created_by, pid),
)
else:
fetch_one(
"""
INSERT INTO packaging_outputs (id, project_id, spec, svg, created_by, created_at)
VALUES (%s, %s, %s::jsonb, %s, %s, NOW())
RETURNING id
""",
(pid, cockpit_project_id, json.dumps(spec_data), svg, spec.created_by),
)
if cockpit_project_id:
_link_asset(cockpit_project_id, pid, spec_data, spec.created_by)
return {
"id": pid,
"cockpit_project_id": cockpit_project_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"svg": svg,
"spec": spec_data,
}
@router.get("/types")
def packaging_types() -> dict[str, Any]:
labels = {
"folding_box": "Folding box (sluitdoos)",
"wrap": "Wrap / banderole",
"round_label": "Rond label",
"sleeve": "Sleeve / huls",
"pouch": "Pouch / zak",
"tray": "Tray / schaal",
}
return {"items": [{"id": t, "label": labels.get(t, t)} for t in PACKAGING_TYPES]}
@router.post("/preview")
def packaging_preview(spec: PackagingSpec) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
return {"svg": generate_packaging(spec_data), "spec": spec_data}
@router.post("/generate") @router.post("/generate")
def packaging_generate(spec: PackagingSpec) -> dict[str, Any]: def packaging_generate(spec: PackagingSpec) -> dict[str, Any]:
spec_data = spec.model_dump() return _persist_packaging(spec)
if not spec_data["brand"]:
spec_data["brand"] = dict(FOODLINKK_BRAND)
svg = generate_packaging(spec_data)
project_id = uuid4().hex
now = datetime.utcnow().isoformat() + "Z"
_PROJECTS[project_id] = {
"id": project_id,
"created_at": now,
"spec": spec_data,
"svg": svg,
}
return {"id": project_id, "created_at": now, "svg": svg, "spec": spec_data}
@router.get("/projects") @router.get("/projects")
def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]: def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]:
items = sorted(_PROJECTS.values(), key=lambda x: x["created_at"], reverse=True)[:limit] rows = fetch_all(
return { """
"items": [{"id": p["id"], "created_at": p["created_at"], "spec": p["spec"]} for p in items], SELECT po.id, po.project_id AS cockpit_project_id, po.spec, po.created_by, po.created_at,
"count": len(items), cp.name AS project_name, cp.nas_path, c.name AS client_name
} FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
ORDER BY po.created_at DESC
LIMIT %s
""",
(limit,),
)
return {"items": [_serialize_row(dict(r)) for r in rows], "count": len(rows)}
@router.get("/download/{project_id}") @router.get("/download/{project_id}")
def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")): def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")):
project = _PROJECTS.get(project_id) project = _get_project(project_id)
if not project: if not project:
raise HTTPException(status_code=404, detail="Packaging project not found") raise HTTPException(status_code=404, detail="Packaging project not found")
svg = project["svg"] svg = project["svg"]
filename = f"foodlinkk-packaging-{project_id[:8]}.{format}" slug = (project.get("spec") or {}).get("design_name") or project_id[:8]
safe_slug = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower())[:40]
filename = f"foodlinkk-{safe_slug}.{format}"
if format == "svg": if format == "svg":
return Response( return Response(
content=svg.encode("utf-8"), content=svg.encode("utf-8"),
@@ -78,3 +250,57 @@ def packaging_download(project_id: str, format: str = Query(default="svg", patte
media_type="application/pdf", media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
@router.get("/{packaging_id}")
def packaging_get(packaging_id: str) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
return {"item": row}
@router.patch("/{packaging_id}")
def packaging_update(packaging_id: str, body: PackagingUpdate) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
existing = _get_project(packaging_id) or {}
spec = body.spec
if not spec.project_id and existing.get("project_id"):
spec.project_id = int(existing["project_id"])
result = _persist_packaging(spec, packaging_id=packaging_id)
return result
@router.post("/{packaging_id}/duplicate")
def packaging_duplicate(packaging_id: str, created_by: str = Query(default="ceo")) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or spec.get("text", {}).get("product_name") or "Design") + " (kopie)"
payload = PackagingSpec(**{**spec, "project_id": int(row["project_id"]) if row.get("project_id") else None, "created_by": created_by})
return _persist_packaging(payload)
@router.post("/{packaging_id}/copy-to-project")
def packaging_copy_to_project(packaging_id: str, body: CopyToProjectBody) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
target = fetch_one("SELECT id, name FROM cockpit_projects WHERE id = %s", (body.target_project_id,))
if not target:
raise HTTPException(status_code=404, detail="Target project not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or "Design") + f"{target['name']}"
payload = PackagingSpec(**{**spec, "project_id": body.target_project_id, "created_by": body.created_by})
return _persist_packaging(payload)
@router.delete("/{packaging_id}")
def packaging_delete(packaging_id: str) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
execute("DELETE FROM project_assets WHERE asset_type = 'packaging' AND ref_id = %s", (packaging_id,))
execute("DELETE FROM packaging_outputs WHERE id = %s", (packaging_id,))
return {"ok": True, "id": packaging_id}
+19
View File
@@ -177,6 +177,25 @@ def run_research() -> dict[str, Any]:
title="Full research cycle completed", title="Full research cycle completed",
metadata={"briefs": 3, "snapshots": len(snapshot_ids)}, metadata={"briefs": 3, "snapshots": len(snapshot_ids)},
) )
from app.connectors.project_assets import register_agent_output
for brief, domain in [
(crm_brief, "crm"),
(retail_brief, "retail"),
(social_brief, "social"),
]:
try:
register_agent_output(
asset_type="research_brief",
title=brief.get("title") or f"Research {domain}",
ref_id=str(brief.get("id")),
payload={"domain": domain, "summary": brief.get("summary"), "brief_id": brief.get("id")},
source_agent="research",
)
except Exception:
pass
return { return {
"snapshots": snapshot_ids, "snapshots": snapshot_ids,
"briefs": [crm_brief["id"], retail_brief["id"], social_brief["id"]], "briefs": [crm_brief["id"], retail_brief["id"], social_brief["id"]],
+98 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.db import execute_returning, fetch_all, fetch_one from app.db import execute, execute_returning, fetch_all, fetch_one
from app.middleware import log_agent_event from app.middleware import log_agent_event
from app import retail_scrapers from app import retail_scrapers
from app import retail_enrichment from app import retail_enrichment
@@ -35,6 +35,49 @@ class CrmLinkIn(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class SupermarketCreateIn(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
data_source: str = "manual"
class SupermarketPatchIn(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
STORE_SELECT = """ STORE_SELECT = """
SELECT s.*, SELECT s.*,
a.population AS area_population, a.population AS area_population,
@@ -360,6 +403,60 @@ def get_supermarket(store_id: int) -> dict[str, Any]:
return data return data
@router.post("/supermarkets")
def create_supermarket(payload: SupermarketCreateIn) -> dict[str, Any]:
row = execute_returning(
"""INSERT INTO supermarkets (
name, chain, address, postcode, city, province, phone, email, website,
manager_name, employee_count, store_type, partnership_status,
halal_certified, has_halal_section, halal_certifier, data_source, last_updated
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
RETURNING id""",
(
payload.name.strip(), payload.chain.strip(), payload.address.strip(),
payload.postcode.strip().upper(), payload.city.strip(), payload.province,
payload.phone, payload.email, payload.website, payload.manager_name,
payload.employee_count, payload.store_type, payload.partnership_status,
payload.halal_certified, payload.has_halal_section, payload.halal_certifier,
payload.data_source,
),
)
store_id = int(row["id"])
log_agent_event(
agent_name="retail_crm",
event_type="create",
title=f"Handmatig filiaal toegevoegd: {payload.name} ({payload.chain})",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@router.patch("/supermarkets/{store_id}")
def patch_supermarket(store_id: int, payload: SupermarketPatchIn) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM supermarkets WHERE id = %s", (store_id,))
if not existing:
raise HTTPException(404, "Supermarket not found")
data = payload.model_dump(exclude_unset=True)
if not data:
raise HTTPException(400, "Geen velden om bij te werken")
allowed = set(SupermarketPatchIn.model_fields.keys())
sets: list[str] = []
params: list[Any] = []
for key, val in data.items():
if key not in allowed:
continue
sets.append(f"{key} = %s")
params.append(val)
sets.append("last_updated = NOW()")
params.append(store_id)
execute(f"UPDATE supermarkets SET {', '.join(sets)} WHERE id = %s", tuple(params))
log_agent_event(
agent_name="retail_crm",
event_type="update",
title=f"Supermarkt #{store_id} data aangevuld",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@router.get("/scrape/chains") @router.get("/scrape/chains")
def list_scrape_chains() -> dict[str, Any]: def list_scrape_chains() -> dict[str, Any]:
return {"chains": retail_scrapers.list_chains()} return {"chains": retail_scrapers.list_chains()}
+34
View File
@@ -94,3 +94,37 @@ def list_crm_options() -> dict[str, Any]:
WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC LIMIT 200""" WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC LIMIT 200"""
) )
return {"clients": [dict(c) for c in clients], "deals": [dict(d) for d in deals]} return {"clients": [dict(c) for c in clients], "deals": [dict(d) for d in deals]}
def get_client_retail_links(client_id: int) -> list[dict[str, Any]]:
rows = 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[str, Any]] = {}
for row in list(rows) + list(direct):
merged[int(row["id"])] = dict(row)
return list(merged.values())