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

Geen kansen — open Retail 360

'; return; } - container.innerHTML = opps.map(function (o) { - var score = Math.round(Number(o.halal_opportunity_score) || 0); - return '' + (o.chain || '') + ' · ' + (o.name || '') + '
' + (o.city || '') + '
' + score + '/100
'; + var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6); + if (!items.length) { + container.innerHTML = '

Geen trending — RSS ophalen in Marketing Hub

'; + return; + } + container.innerHTML = items.map(function (r) { + var when = (r.published_at || '').substring(0, 10); + return '' + + '' + (r.title || '') + '
' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '
' + + '' + ((r.category || 'food').toUpperCase()) + '
'; }).join(''); } @@ -289,16 +294,17 @@ window.BriefingCharts = (function () { { icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' }, { icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' }, ]; - var opps = (stats.top_opportunities || []).slice(0, 3); + var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5); var ms = (stats.milestones_pending || []).slice(0, 3); var html = '
' + items.map(function (it) { return '' + it.icon + '' + '
' + it.label + '
' + it.val + '
'; }).join('') + '
'; - if (opps.length) { - html += '

Top halal kansen

'; } - html += ''; + var pending = stats.pending_approval_requests || []; + if (pending.length) { + html += '

⏳ Open goedkeuringen

    '; + pending.forEach(function (p) { + html += '
  • @' + (p.agent_key || '') + ' · ' + (p.title || '') + '
  • '; + }); + html += '
'; + } + html += ''; container.innerHTML = html; } @@ -325,21 +343,33 @@ window.BriefingCharts = (function () { if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '
  • ' + a + '
  • '; }).join('') : '
  • Halal kant-en-klaar partnerships schalen
  • '; } - function renderLive(root, stats) { + function renderLive(root, stats, vizMode) { if (!root || !stats) return; + var mode = vizMode || window._dashboardVizMode || 'neo-bars'; renderKpis(document.getElementById('briefing-kpis'), stats); renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats); renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats); renderRetail(document.getElementById('briefing-retail'), stats); renderMilestones(document.getElementById('briefing-milestones'), stats); + var actEl = document.getElementById('briefing-activity-log'); + if (actEl) { + var log = stats.activity_log || []; + actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '
  • ' + e + '
  • '; }).join('') + : '
  • Nog geen agent-acties vandaag.
  • '; + } renderPipeline(document.getElementById('chart-pipeline'), stats); - renderSentiment(document.getElementById('chart-sentiment'), stats); renderWords(document.getElementById('chart-words'), stats); - renderAgents(document.getElementById('chart-agents'), stats); + if (window.VizEngine) { + VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats); + VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats); + } else { + renderSentiment(document.getElementById('chart-sentiment'), stats); + renderAgents(document.getElementById('chart-agents'), stats); + } } - function render(root, stats, content, createdAt) { - renderLive(root, stats); + function render(root, stats, content, createdAt, vizMode) { + renderLive(root, stats, vizMode); renderText(root, content, createdAt); } diff --git a/cockpit/static/js/cockpit.js b/cockpit/static/js/cockpit.js index ffb9853..e7f597a 100644 --- a/cockpit/static/js/cockpit.js +++ b/cockpit/static/js/cockpit.js @@ -40,7 +40,9 @@ window.Cockpit = (function () { } function confirmDelete(message) { - return window.confirm(message || 'Delete this item?'); + var msg = message; + if (!msg && window.I18n) msg = window.I18n.t('common.confirm_delete'); + return window.confirm(msg || 'Delete this item?'); } function openDrawer(id) { diff --git a/cockpit/static/js/dashboard-layout.js b/cockpit/static/js/dashboard-layout.js new file mode 100644 index 0000000..508ed9c --- /dev/null +++ b/cockpit/static/js/dashboard-layout.js @@ -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 = '' + + global.VizEngine.MODES.map(function (m) { + var active = prefs.global_viz_mode === m.id ? ' active' : ''; + return ''; + }).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); diff --git a/cockpit/static/js/global-viz.js b/cockpit/static/js/global-viz.js new file mode 100644 index 0000000..25aa3e1 --- /dev/null +++ b/cockpit/static/js/global-viz.js @@ -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 ''; + }).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); diff --git a/cockpit/static/js/i18n.js b/cockpit/static/js/i18n.js new file mode 100644 index 0000000..2fa56f0 --- /dev/null +++ b/cockpit/static/js/i18n.js @@ -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); }; + }); +}); diff --git a/cockpit/static/js/ops-topology.js b/cockpit/static/js/ops-topology.js new file mode 100644 index 0000000..1716320 --- /dev/null +++ b/cockpit/static/js/ops-topology.js @@ -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 }; +})(); diff --git a/cockpit/static/js/page-viz.js b/cockpit/static/js/page-viz.js new file mode 100644 index 0000000..2f43de0 --- /dev/null +++ b/cockpit/static/js/page-viz.js @@ -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); diff --git a/cockpit/static/js/viz-engine.js b/cockpit/static/js/viz-engine.js new file mode 100644 index 0000000..e0e66f3 --- /dev/null +++ b/cockpit/static/js/viz-engine.js @@ -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 '
    ' + + counts.map(function (v) { + var h = Math.max(18, Math.round((v / max) * 92)); + return ''; + }).join('') + '
    '; + } + + 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 = '
    ' + rows.map(function (r) { + return '
    ' + r.label + '' + r.value + '
    '; + }).join('') + '
    '; + } + + function renderTable(container, rows) { + if (!container) return; + container.innerHTML = '' + + rows.map(function (r) { return ''; }).join('') + + '
    ItemWaarde
    ' + r.label + '' + r.value + '
    '; + } + + function renderEqualizer(container, rows, cls) { + if (!container) return; + container.innerHTML = rows.map(function (r) { + return '
    ' + r.label + '' + r.value + '
    '; + }).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); diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index dd70de8..2d1d4d1 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -1,8 +1,11 @@ {% extends "base.html" %} {% block extra_head %} + - + + + {% endblock %} {% block content %}
    @@ -11,6 +14,15 @@

    Animated team · duidelijke rollen · soul.md · live activiteit

    +
    +
    +

    Agent activiteit (events)

    5 visualisatie-modi
    +
    +
    +
    +
    +
    +
    @@ -27,54 +43,41 @@
    🤖
    Agents
    geregistreerd
    0
    Events
    totaal gelogd
    0
    Actief
    agents online
    0
    -
    👑
    Herman
    orchestrator
    CEO
    +
    👑
    Herman
    Co-CEO · takenverdeler
    Co-CEO
    -
    -
    -
    - - - - - -
    -
    -
    +

    @@ -87,9 +90,16 @@ -

    Recente activiteit

    +
    +

    Recente activiteit

    + +

    Nog geen events voor deze agent.

    @@ -98,7 +108,7 @@

    Netwerk

    -

    Communication mesh met Herman als centrale Co-CEO hub.

    +

    Agents onderaan → Herman (Co-CEO) → CEO & CTO. Pulse = actieve communicatie.

    @@ -110,11 +120,82 @@
    + +
    +
    +
    +
    +

    Approval queue

    +

    Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.

    +
    + +
    + + + + + +
    #AgentActieTitelStatusTijd
    +

    Geen approval requests.

    +
    +
    + +
    +
    {% endblock %} {% block scripts %} + + {% endblock %} diff --git a/cockpit/templates/analytics.html b/cockpit/templates/analytics.html index 7e46d0c..66d69e9 100644 --- a/cockpit/templates/analytics.html +++ b/cockpit/templates/analytics.html @@ -71,5 +71,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/cockpit/templates/base.html b/cockpit/templates/base.html index 731a183..7d29bfa 100644 --- a/cockpit/templates/base.html +++ b/cockpit/templates/base.html @@ -17,6 +17,7 @@ + {% block extra_head %}{% endblock %} @@ -30,46 +31,48 @@
    -
    Hermes
    - Telegram +
    Hermes
    + Telegram
    -
    System
    - Analytics - Voice - IT Ops - Settings +
    Systeem
    + Analytics + Voice + IT Ops + Instellingen + Gitea
    @@ -79,6 +82,11 @@
    Foodlinkk +
    ·
    {% block content %}{% endblock %}
    @@ -91,11 +99,15 @@ - + + + + diff --git a/cockpit/templates/dashboard.html b/cockpit/templates/dashboard.html index e62b623..cd8fd5c 100644 --- a/cockpit/templates/dashboard.html +++ b/cockpit/templates/dashboard.html @@ -2,14 +2,25 @@ {% block extra_head %} - + {% endblock %} {% block content %}

    Herman · Command Center Live

    -

    CEO overzicht · klanten · briefing · retail · alles op een rij

    +

    CEO overzicht · trending retail · briefing · platform links

    +
    @@ -24,6 +35,7 @@
    + CRM Clients @@ -32,29 +44,38 @@
    +
    +
    -
    +
    +
    Sleep widget · CEO KPI's

    CEO KPI's

    -
    +
    +
    Sleep widget · Executive

    Alles op een rij

    -

    Executive samenvatting

    live database · geen RSS — zie Marketing Hub
    +

    Executive samenvatting

    live database
    -
    +
    +
    Sleep widget · Briefing

    Herman briefing

    Samenvatting

    +
    +

    📋 Herman activiteitenlog

    +
      +

      ⚡ Korte termijn

      @@ -68,12 +89,14 @@
      -
      +
      +
      Sleep widget · Retail

      Retail operatie

      -

      Top retail kansen

      Retail 360 →
      +

      Trending food retail

      Marketing Hub →
      +

      Sales milestones

      live DB
      @@ -86,72 +109,153 @@
      -
      -

      Data & analytics

      +
      +
      Sleep widget · Analytics
      +

      Data & analytics

      -
      +

      Pipeline

      live deals
      +
      -
      -

      NAS sentiment

      documenten
      -
      +
      +

      NAS sentiment

      neo-bars
      +
      +
      +
      +
      -
      +

      NAS top woorden

      corpus
      +
      -
      -

      Agent activiteit

      laatste events
      -
      +
      +

      Agent activiteit

      neo-bars
      +
      +
      +
      +
      +
      +
      Sleep widget · Goedkeuringen
      +

      Agent goedkeuringen

      +
      +

      SysOps vraagt dagelijks toestemming voor update-scan en Gitea backup. Keur goed → actie wordt automatisch uitgevoerd.

      +
      +
      +
      + +
      +
      Sleep widget · Feed
      +
      +

      Agent feed Live

      +
      + {% for ev in agent_feed[:12] %} +
      + {{ ev.created_at[11:16] if ev.created_at else '' }} + {{ ev.agent_name }} + {{ ev.title or ev.event_type }} +
      + {% else %}

      Nog geen events.

      {% endfor %} +
      +
      +
      +

      Projecten Alle projecten →

      +

      Laden…

      +
      +
      +
      - -
      -
      -

      Agent feed Live

      -
      - {% for ev in agent_feed[:12] %} -
      - {{ ev.created_at[11:16] if ev.created_at else '' }} - {{ ev.agent_name }} - {{ ev.title or ev.event_type }} -
      - {% else %}

      Nog geen events.

      {% endfor %} -
      -
      -
      -

      Wacht op OK

      - {% for item in approvals[:8] %} -
      {{ item.agent_name }} {{ item.title or item.event_type }}
      - {% else %}

      Geen goedkeuringen 🎉

      {% endfor %} -
      -
      {% endblock %} {% block scripts %} - + + + + {% endblock %} {% block content %} -
      -