SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -35,6 +35,7 @@ class PublishRequest(BaseModel):
|
||||
image_url: str | None = None
|
||||
media_ids: list[int] = Field(default_factory=list)
|
||||
channels: list[str] = Field(default_factory=list)
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@@ -91,7 +92,9 @@ def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks)
|
||||
),
|
||||
)
|
||||
job_id = int(row["id"])
|
||||
background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids)
|
||||
background_tasks.add_task(
|
||||
run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids, body.project_id,
|
||||
)
|
||||
return {"job_id": job_id, "status": "queued", "channels": channels}
|
||||
|
||||
|
||||
|
||||
+201
-40
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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}))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"crm": "CRM",
|
||||
"clients": "Klanten",
|
||||
"deals": "Deals",
|
||||
"products": "Producten",
|
||||
"suppliers": "Leveranciers",
|
||||
"intel": "Intel",
|
||||
"beurs": "Beurs Live",
|
||||
"retail": "Retail",
|
||||
"browser": "Browser",
|
||||
"documents": "Documenten",
|
||||
"projects": "Projecten",
|
||||
"hermes": "Hermes",
|
||||
"telegram": "Telegram",
|
||||
"agents": "Agents",
|
||||
"marketing": "Marketing",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"packaging": "Packaging",
|
||||
"reports": "Rapporten",
|
||||
"system": "Systeem",
|
||||
"analytics": "Analytics",
|
||||
"voice": "Voice",
|
||||
"ops": "IT Ops",
|
||||
"settings": "Instellingen",
|
||||
"gitea": "Gitea"
|
||||
},
|
||||
"common": {
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"add": "Toevoegen",
|
||||
"search": "Zoeken",
|
||||
"refresh": "Verversen",
|
||||
"loading": "Laden…",
|
||||
"none": "—",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
|
||||
"saved": "Opgeslagen",
|
||||
"deleted": "Verwijderd",
|
||||
"error": "Fout",
|
||||
"all": "Alle",
|
||||
"actions": "Acties",
|
||||
"language": "Taal",
|
||||
"dutch": "Nederlands",
|
||||
"english": "English"
|
||||
},
|
||||
"clients": {
|
||||
"title": "CRM · Klanten",
|
||||
"subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
|
||||
"new": "Nieuwe klant",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "Lijst",
|
||||
"tab_stores": "Supermarkten"
|
||||
},
|
||||
"products": {
|
||||
"title": "Productcatalogus",
|
||||
"subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
|
||||
"new": "Nieuw product",
|
||||
"tab_grid": "Overzicht",
|
||||
"tab_list": "Lijst",
|
||||
"tab_margin": "Marge calculator",
|
||||
"total": "Totaal producten",
|
||||
"active": "Actief",
|
||||
"avg_margin": "Gem. marge",
|
||||
"with_client": "Met klant",
|
||||
"name": "Productnaam",
|
||||
"status": "Status",
|
||||
"margin_pct": "Marge %",
|
||||
"moq": "MOQ",
|
||||
"shelf_target": "Schap / listing",
|
||||
"launch_date": "Launch datum",
|
||||
"client": "Klant",
|
||||
"status_concept": "Concept",
|
||||
"status_development": "Ontwikkeling",
|
||||
"status_active": "Actief",
|
||||
"status_discontinued": "Stopgezet",
|
||||
"calc_cost": "Inkoopprijs",
|
||||
"calc_sell": "Verkoopprijs",
|
||||
"calc_profit": "Winst",
|
||||
"calc_margin": "Marge",
|
||||
"calc_run": "Berekenen",
|
||||
"no_products": "Nog geen producten — voeg je eerste SKU toe.",
|
||||
"drawer_deals": "Gekoppelde klant"
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Leveranciers",
|
||||
"subtitle": "Vendor registry · ratings · levertijden · contacten",
|
||||
"new": "Nieuwe leverancier",
|
||||
"tab_grid": "Overzicht",
|
||||
"tab_list": "Lijst",
|
||||
"total": "Totaal leveranciers",
|
||||
"avg_rating": "Gem. rating",
|
||||
"countries": "Landen",
|
||||
"avg_lead": "Gem. levertijd",
|
||||
"name": "Bedrijfsnaam",
|
||||
"country": "Land",
|
||||
"category": "Categorie",
|
||||
"rating": "Rating",
|
||||
"moq": "MOQ",
|
||||
"lead_time": "Levertijd (dagen)",
|
||||
"contact": "Contact",
|
||||
"cat_meat": "Vlees",
|
||||
"cat_dairy": "Zuivel",
|
||||
"cat_spices": "Kruiden & specerijen",
|
||||
"cat_packaging": "Verpakking",
|
||||
"cat_logistics": "Logistiek",
|
||||
"cat_ingredients": "Ingrediënten",
|
||||
"cat_other": "Overig",
|
||||
"no_suppliers": "Nog geen leveranciers geregistreerd.",
|
||||
"filter_country": "Filter land",
|
||||
"filter_category": "Filter categorie"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
@@ -0,0 +1,109 @@
|
||||
/** 3D-style agent poppetjes met headset — consistent team look */
|
||||
window.AgentAvatars = (function () {
|
||||
var CFG = {
|
||||
herman: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'exec', accent: '#ffd700' },
|
||||
marketing: { hoodie: '#9d174d', hair: '#1f2937', skin: '#f5c9a8', style: 'bob', accent: '#ff6b9d' },
|
||||
bizdev: { hoodie: '#0e4d6e', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#00e5ff' },
|
||||
finance: { hoodie: '#14532d', hair: '#3d2314', skin: '#e8b896', style: 'glasses', accent: '#22c55e' },
|
||||
sourcing: { hoodie: '#9a3412', hair: '#1a1a1a', skin: '#c9a07a', style: 'short', accent: '#f97316' },
|
||||
product: { hoodie: '#581c87', hair: '#4a3728', skin: '#f5c9a8', style: 'curl', accent: '#a855f7' },
|
||||
halal: { hoodie: '#065f46', hair: '#1f2937', skin: '#d4a574', style: 'short', accent: '#34d399' },
|
||||
design: { hoodie: '#831843', hair: '#111827', skin: '#f5c9a8', style: 'long', accent: '#ec4899' },
|
||||
knowledge: { hoodie: '#312e81', hair: '#374151', skin: '#e8b896', style: 'glasses', accent: '#6366f1' },
|
||||
retail: { hoodie: '#365314', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#b8ff3c' },
|
||||
browser: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#c9a07a', style: 'short', accent: '#38bdf8' },
|
||||
hermes: { hoodie: '#1e40af', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#2aabee' },
|
||||
email: { hoodie: '#334155', hair: '#4a3728', skin: '#ddb896', style: 'short', accent: '#94a3b8' },
|
||||
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
|
||||
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
|
||||
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
|
||||
};
|
||||
|
||||
function cfg(key, fallbackColor) {
|
||||
var k = (key || 'agent').toLowerCase();
|
||||
var c = CFG[k] || { hoodie: fallbackColor || '#1e3a5f', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#38bdf8' };
|
||||
return c;
|
||||
}
|
||||
|
||||
function capSvg(hid, c) {
|
||||
return (
|
||||
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.35"/>' +
|
||||
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + c.hoodie + '"/>' +
|
||||
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="#1e40af"/>' +
|
||||
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="#38bdf8" stroke-width="1.2" opacity="0.5"/>' +
|
||||
'<rect x="46" y="28" width="8" height="5" rx="1" fill="#38bdf8" opacity="0.85"/>'
|
||||
);
|
||||
}
|
||||
|
||||
function hairPath(style) {
|
||||
if (style === 'long') return 'M28,38 Q20,18 40,14 Q60,10 72,22 Q78,32 74,48 Q70,56 62,52 Q58,38 50,36 Q42,36 38,48 Q32,52 28,38Z';
|
||||
if (style === 'bob') return 'M26,40 Q24,22 42,16 Q62,14 74,28 Q76,42 70,50 Q50,54 30,48 Q24,44 26,40Z';
|
||||
if (style === 'curl') return 'M28,42 Q22,24 38,16 Q52,12 68,20 Q76,30 72,44 Q66,52 54,48 Q44,52 34,46 Q26,44 28,42Z';
|
||||
return 'M30,40 Q28,24 44,18 Q58,16 70,26 Q74,36 70,44 Q54,48 38,44 Q30,42 30,40Z';
|
||||
}
|
||||
|
||||
function svgAvatar(key, color) {
|
||||
var c = cfg(key, color);
|
||||
var hid = 'av-' + key;
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" class="agent-char-svg" aria-hidden="true">' +
|
||||
'<defs>' +
|
||||
'<linearGradient id="' + hid + '-bg" x1="0%" y1="0%" x2="100%" y2="100%">' +
|
||||
'<stop offset="0%" stop-color="#0c1929"/><stop offset="50%" stop-color="#152238"/><stop offset="100%" stop-color="#0a1020"/>' +
|
||||
'</linearGradient>' +
|
||||
'<linearGradient id="' + hid + '-hood" x1="30%" y1="0%" x2="70%" y2="100%">' +
|
||||
'<stop offset="0%" stop-color="' + c.hoodie + '"/><stop offset="100%" stop-color="#0a0f18"/>' +
|
||||
'</linearGradient>' +
|
||||
'<linearGradient id="' + hid + '-skin" x1="40%" y1="0%" x2="60%" y2="100%">' +
|
||||
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#c9956e"/>' +
|
||||
'</linearGradient>' +
|
||||
'<radialGradient id="' + hid + '-glow" cx="75%" cy="25%" r="45%">' +
|
||||
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.35"/><stop offset="100%" stop-opacity="0"/>' +
|
||||
'</radialGradient>' +
|
||||
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.45"/></filter>' +
|
||||
'</defs>' +
|
||||
'<rect width="100" height="120" fill="url(#' + hid + '-bg)"/>' +
|
||||
'<ellipse cx="78" cy="28" rx="35" ry="30" fill="url(#' + hid + '-glow)"/>' +
|
||||
'<ellipse cx="50" cy="100" rx="30" ry="14" fill="rgba(0,0,0,0.4)"/>' +
|
||||
'<path d="M18,118 Q18,74 50,68 Q82,74 82,118Z" fill="url(#' + hid + '-hood)" filter="url(#' + hid + '-sh)"/>' +
|
||||
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + c.hoodie + '" opacity="0.9"/>' +
|
||||
'<path d="M28,68 Q50,48 72,68 L68,78 Q50,72 32,78Z" fill="' + c.hoodie + '"/>' +
|
||||
'<ellipse cx="50" cy="54" rx="23" ry="25" fill="url(#' + hid + '-skin)" filter="url(#' + hid + '-sh)"/>' +
|
||||
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(0,0,0,0.08)"/>' +
|
||||
(c.hat === 'cap' ? '' : '<path d="' + hairPath(c.style) + '" fill="' + c.hair + '"/>') +
|
||||
(c.style === 'glasses'
|
||||
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
|
||||
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
|
||||
'<line x1="50" y1="52" x2="50" y2="52" stroke="#334155" stroke-width="1.5"/>'
|
||||
: '') +
|
||||
'<ellipse cx="42" cy="52" rx="3" ry="3.5" fill="#1e293b"/><ellipse cx="58" cy="52" rx="3" ry="3.5" fill="#1e293b"/>' +
|
||||
'<ellipse cx="43" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/><ellipse cx="59" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/>' +
|
||||
'<path d="M44,62 Q50,66 56,62" fill="none" stroke="#b45309" stroke-width="1.5" stroke-linecap="round"/>' +
|
||||
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#cbd5e1" stroke-width="4" stroke-linecap="round"/>' +
|
||||
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
|
||||
'<rect x="12" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
|
||||
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
|
||||
'<rect x="78" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
|
||||
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#94a3b8" stroke-width="2.8" stroke-linecap="round"/>' +
|
||||
'<circle cx="82" cy="84" r="3.5" fill="#64748b" stroke="#94a3b8" stroke-width="1"/>' +
|
||||
'<circle cx="88" cy="32" r="2.5" fill="' + c.accent + '" opacity="0.9"/>' +
|
||||
'<circle cx="12" cy="40" r="1.5" fill="' + c.accent + '" opacity="0.5"/>' +
|
||||
(c.hat === 'cap' ? capSvg(hid, c) : '') +
|
||||
'</svg>'
|
||||
);
|
||||
}
|
||||
|
||||
function paint(el, key, color) {
|
||||
if (!el) return;
|
||||
el.innerHTML = svgAvatar((key || '').toLowerCase(), color);
|
||||
}
|
||||
|
||||
function paintAll(root) {
|
||||
root = root || document;
|
||||
root.querySelectorAll('[data-agent-avatar]').forEach(function (el) {
|
||||
paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color'));
|
||||
});
|
||||
}
|
||||
|
||||
return { paint: paint, paintAll: paintAll, svgAvatar: svgAvatar };
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ window.BriefingCharts = (function () {
|
||||
{ label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' },
|
||||
{ label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' },
|
||||
{ label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') },
|
||||
{ label: 'Halal kansen', sub: 'top score', icon: '🎯', color: '#a855f7', pct: '65%', val: stats.top_opportunities && stats.top_opportunities[0] ? Math.round(Number(stats.top_opportunities[0].halal_opportunity_score || 0)) + '/100' : '—' },
|
||||
{ label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', pct: Math.min(95, ((stats.trending_food || stats.food_market_highlights || []).length * 10)) + '%', val: (stats.trending_food || stats.food_market_highlights || []).length || 0, link: '/marketing' },
|
||||
{ label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
|
||||
];
|
||||
container.className = 'hm-neo-kpi-row';
|
||||
@@ -257,11 +257,16 @@ window.BriefingCharts = (function () {
|
||||
|
||||
function renderRetail(container, stats) {
|
||||
if (!container) return;
|
||||
var opps = stats.top_opportunities || [];
|
||||
if (!opps.length) { container.innerHTML = '<p class="empty-state">Geen kansen — <a href="/retail">open Retail 360</a></p>'; return; }
|
||||
container.innerHTML = opps.map(function (o) {
|
||||
var score = Math.round(Number(o.halal_opportunity_score) || 0);
|
||||
return '<a href="/retail" class="retail-highlight"><span><strong>' + (o.chain || '') + ' · ' + (o.name || '') + '</strong><br><small>' + (o.city || '') + '</small></span><span class="ticker-item">' + score + '/100</span></a>';
|
||||
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (r) {
|
||||
var when = (r.published_at || '').substring(0, 10);
|
||||
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
|
||||
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
|
||||
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -289,16 +294,17 @@ window.BriefingCharts = (function () {
|
||||
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
|
||||
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
|
||||
];
|
||||
var opps = (stats.top_opportunities || []).slice(0, 3);
|
||||
var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5);
|
||||
var ms = (stats.milestones_pending || []).slice(0, 3);
|
||||
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
|
||||
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
|
||||
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
|
||||
}).join('') + '</div>';
|
||||
if (opps.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Top halal kansen</h4><ul class="hm-exec-list">';
|
||||
opps.forEach(function (o) {
|
||||
html += '<li><a href="/retail">' + (o.chain || '') + ' · ' + (o.name || '') + ' (' + (o.city || '') + ') — ' + Math.round(Number(o.halal_opportunity_score || 0)) + '/100</a></li>';
|
||||
if (trending.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
|
||||
trending.forEach(function (r) {
|
||||
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
|
||||
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
@@ -309,7 +315,19 @@ window.BriefingCharts = (function () {
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">RSS & reclame folders → <a href="/marketing">Marketing Hub</a> · Volledige analytics → <a href="/analytics">Analytics</a></p>';
|
||||
var pending = stats.pending_approval_requests || [];
|
||||
if (pending.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
|
||||
pending.forEach(function (p) {
|
||||
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
|
||||
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
|
||||
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
|
||||
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
|
||||
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -325,21 +343,33 @@ window.BriefingCharts = (function () {
|
||||
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
|
||||
}
|
||||
|
||||
function renderLive(root, stats) {
|
||||
function renderLive(root, stats, vizMode) {
|
||||
if (!root || !stats) return;
|
||||
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
|
||||
renderKpis(document.getElementById('briefing-kpis'), stats);
|
||||
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
|
||||
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
|
||||
renderRetail(document.getElementById('briefing-retail'), stats);
|
||||
renderMilestones(document.getElementById('briefing-milestones'), stats);
|
||||
var actEl = document.getElementById('briefing-activity-log');
|
||||
if (actEl) {
|
||||
var log = stats.activity_log || [];
|
||||
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
|
||||
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
|
||||
}
|
||||
renderPipeline(document.getElementById('chart-pipeline'), stats);
|
||||
renderSentiment(document.getElementById('chart-sentiment'), stats);
|
||||
renderWords(document.getElementById('chart-words'), stats);
|
||||
renderAgents(document.getElementById('chart-agents'), stats);
|
||||
if (window.VizEngine) {
|
||||
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
|
||||
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
|
||||
} else {
|
||||
renderSentiment(document.getElementById('chart-sentiment'), stats);
|
||||
renderAgents(document.getElementById('chart-agents'), stats);
|
||||
}
|
||||
}
|
||||
|
||||
function render(root, stats, content, createdAt) {
|
||||
renderLive(root, stats);
|
||||
function render(root, stats, content, createdAt, vizMode) {
|
||||
renderLive(root, stats, vizMode);
|
||||
renderText(root, content, createdAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
(function (global) {
|
||||
var WIDGET_META = {
|
||||
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
||||
executive: { title: 'Alles op een rij', icon: '📋' },
|
||||
briefing: { title: 'Herman briefing', icon: '📝' },
|
||||
retail: { title: 'Retail operatie', icon: '🏪' },
|
||||
analytics: { title: 'Data & analytics', icon: '📈' },
|
||||
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
||||
feed: { title: 'Agent feed', icon: '⚡' },
|
||||
};
|
||||
|
||||
function loadPrefs() {
|
||||
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
|
||||
return { dashboard_layout: Object.keys(WIDGET_META), global_viz_mode: 'neo-bars', viz_modes: {} };
|
||||
});
|
||||
}
|
||||
|
||||
function saveLayout(order) {
|
||||
return fetch('/api/preferences/ui', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dashboard_layout: order }),
|
||||
});
|
||||
}
|
||||
|
||||
function saveViz(globalMode, vizModes) {
|
||||
return fetch('/api/preferences/ui', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ global_viz_mode: globalMode, viz_modes: vizModes || {} }),
|
||||
});
|
||||
}
|
||||
|
||||
function applyOrder(container, order) {
|
||||
if (!container) return;
|
||||
var map = {};
|
||||
Array.from(container.children).forEach(function (el) {
|
||||
if (el.dataset && el.dataset.widget) map[el.dataset.widget] = el;
|
||||
});
|
||||
order.forEach(function (id) {
|
||||
if (map[id]) container.appendChild(map[id]);
|
||||
});
|
||||
}
|
||||
|
||||
function initDrag(container, onReorder) {
|
||||
if (!container) return;
|
||||
var dragEl = null;
|
||||
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
|
||||
el.setAttribute('draggable', 'true');
|
||||
el.classList.add('hm-widget-draggable');
|
||||
el.addEventListener('dragstart', function (ev) {
|
||||
dragEl = el;
|
||||
el.classList.add('hm-widget-dragging');
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
el.addEventListener('dragend', function () {
|
||||
el.classList.remove('hm-widget-dragging');
|
||||
dragEl = null;
|
||||
var order = Array.from(container.querySelectorAll('[data-widget]')).map(function (n) { return n.dataset.widget; });
|
||||
if (onReorder) onReorder(order);
|
||||
});
|
||||
el.addEventListener('dragover', function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!dragEl || dragEl === el) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
if (after) el.after(dragEl); else el.before(dragEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildVizSelector(container, prefs, onChange) {
|
||||
if (!container || !global.VizEngine) return;
|
||||
container.innerHTML = '<label class="viz-global-label">Visualisatie:</label>' +
|
||||
global.VizEngine.MODES.map(function (m) {
|
||||
var active = prefs.global_viz_mode === m.id ? ' active' : '';
|
||||
return '<button type="button" class="viz-mode-btn' + active + '" data-viz="' + m.id + '">' + m.label + '</button>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.viz-mode-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
container.querySelectorAll('.viz-mode-btn').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
if (onChange) onChange(btn.dataset.viz);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
global.DashboardLayout = {
|
||||
WIDGET_META: WIDGET_META,
|
||||
loadPrefs: loadPrefs,
|
||||
saveLayout: saveLayout,
|
||||
saveViz: saveViz,
|
||||
applyOrder: applyOrder,
|
||||
initDrag: initDrag,
|
||||
buildVizSelector: buildVizSelector,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,57 @@
|
||||
(function (global) {
|
||||
var mode = 'neo-bars';
|
||||
|
||||
function getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
function setMode(m, persist) {
|
||||
mode = m;
|
||||
global.__vizMode = m;
|
||||
document.querySelectorAll('#global-viz-toolbar .global-viz-btn').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.dataset.viz === m);
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('foodlinkk:vizmode', { detail: { mode: m } }));
|
||||
if (persist !== false) {
|
||||
fetch('/api/preferences/ui', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ global_viz_mode: m }),
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolbar(container) {
|
||||
if (!container || !global.VizEngine) return;
|
||||
container.innerHTML = global.VizEngine.MODES.map(function (m) {
|
||||
var short = m.label.replace('Neo ', '');
|
||||
var active = mode === m.id ? ' active' : '';
|
||||
return '<button type="button" class="global-viz-btn' + active + '" data-viz="' + m.id + '" title="' + m.label + '">' + short + '</button>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.global-viz-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { setMode(btn.dataset.viz); });
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (global.location && global.location.pathname === '/') return;
|
||||
fetch('/api/preferences/ui')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (prefs) {
|
||||
mode = prefs.global_viz_mode || 'neo-bars';
|
||||
global.__vizMode = mode;
|
||||
buildToolbar(document.getElementById('global-viz-toolbar'));
|
||||
document.dispatchEvent(new CustomEvent('foodlinkk:vizmode', { detail: { mode: mode } }));
|
||||
})
|
||||
.catch(function () {
|
||||
buildToolbar(document.getElementById('global-viz-toolbar'));
|
||||
});
|
||||
}
|
||||
|
||||
global.GlobalViz = { getMode: getMode, setMode: setMode, init: init };
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window);
|
||||
@@ -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); };
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -0,0 +1,218 @@
|
||||
(function (global) {
|
||||
var MODES = [
|
||||
{ id: 'neo-bars', label: 'Neo staafdiagram' },
|
||||
{ id: 'neo-rings', label: 'Neo ringen' },
|
||||
{ id: 'neo-equalizer', label: 'Neo equalizer' },
|
||||
{ id: 'neo-cards', label: 'Neo kaarten' },
|
||||
{ id: 'neo-table', label: 'Neo tabel' },
|
||||
];
|
||||
|
||||
var charts = {};
|
||||
|
||||
function destroy(key) {
|
||||
if (charts[key]) {
|
||||
charts[key].destroy();
|
||||
charts[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
function colors() {
|
||||
return {
|
||||
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
|
||||
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
|
||||
purple: 'rgba(168, 85, 247, 0.9)', gray: 'rgba(159, 176, 196, 0.85)',
|
||||
grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
|
||||
};
|
||||
}
|
||||
|
||||
function eqBars(counts, cls) {
|
||||
var max = Math.max.apply(null, counts.concat([1]));
|
||||
return '<div class="hm-eq viz-eq ' + (cls || '') + '">' +
|
||||
counts.map(function (v) {
|
||||
var h = Math.max(18, Math.round((v / max) * 92));
|
||||
return '<span style="height:' + h + '%"></span>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function findPanel(canvas, alt) {
|
||||
var el = (canvas && canvas.closest('.page-viz-panel')) || (alt && alt.closest('.page-viz-panel'));
|
||||
return el || null;
|
||||
}
|
||||
|
||||
function applyPanelLayout(canvas, alt, mode) {
|
||||
var panel = findPanel(canvas, alt);
|
||||
if (!panel) return;
|
||||
var isCanvas = mode === 'neo-bars' || mode === 'neo-rings';
|
||||
panel.classList.toggle('viz-mode-chart', isCanvas);
|
||||
panel.classList.toggle('viz-mode-alt', !isCanvas);
|
||||
panel.classList.toggle('viz-mode-rings', mode === 'neo-rings');
|
||||
}
|
||||
|
||||
function useHorizontal(labels, opts) {
|
||||
if (opts && opts.horizontal !== undefined) return opts.horizontal;
|
||||
return (labels || []).length >= 6;
|
||||
}
|
||||
|
||||
function renderBars(canvas, labels, values, opts) {
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
opts = opts || { key: 'chart' };
|
||||
var c = colors();
|
||||
var horiz = useHorizontal(labels, opts);
|
||||
destroy(opts.key);
|
||||
charts[opts.key] = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: opts.color || c.cyan, borderRadius: 6, barThickness: horiz ? 'flex' : undefined, maxBarThickness: horiz ? 28 : 48 }],
|
||||
},
|
||||
options: {
|
||||
indexAxis: horiz ? 'y' : 'x',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { top: 4, right: 8, bottom: 4, left: 4 } },
|
||||
plugins: { legend: { display: false }, title: { display: !!opts.title, text: opts.title, color: c.text, padding: { bottom: 8 } } },
|
||||
scales: {
|
||||
y: horiz
|
||||
? { ticks: { color: c.text, autoSkip: false, font: { size: 11 } }, grid: { color: c.grid } }
|
||||
: { ticks: { color: c.text }, grid: { color: c.grid }, beginAtZero: true },
|
||||
x: horiz
|
||||
? { ticks: { color: c.text }, grid: { display: false }, beginAtZero: true }
|
||||
: { ticks: { color: c.text, maxRotation: 45, minRotation: 0, autoSkip: false, font: { size: 10 } }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
requestAnimationFrame(function () {
|
||||
if (charts[opts.key]) charts[opts.key].resize();
|
||||
});
|
||||
}
|
||||
|
||||
function renderRings(canvas, labels, values, key, title) {
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
var c = colors();
|
||||
destroy(key);
|
||||
charts[key] = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: [c.green, c.gray, c.red, c.gold, c.cyan, c.purple], borderWidth: 0 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '62%',
|
||||
layout: { padding: 4 },
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: c.text, boxWidth: 10, padding: 8, font: { size: 10 } } },
|
||||
title: { display: !!title, text: title, color: c.text, font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
requestAnimationFrame(function () {
|
||||
if (charts[key]) charts[key].resize();
|
||||
});
|
||||
}
|
||||
|
||||
function renderCards(container, rows) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="viz-cards">' + rows.map(function (r) {
|
||||
return '<div class="viz-card"><span class="viz-card-label">' + r.label + '</span><strong class="viz-card-val">' + r.value + '</strong></div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderTable(container, rows) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '<table class="data-table viz-table"><thead><tr><th>Item</th><th>Waarde</th></tr></thead><tbody>' +
|
||||
rows.map(function (r) { return '<tr><td>' + r.label + '</td><td>' + r.value + '</td></tr>'; }).join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderEqualizer(container, rows, cls) {
|
||||
if (!container) return;
|
||||
container.innerHTML = rows.map(function (r) {
|
||||
return '<div class="viz-eq-row"><span>' + r.label + '</span><strong>' + r.value + '</strong></div>';
|
||||
}).join('') + eqBars(rows.map(function (r) { return Number(r.raw) || 0; }), cls);
|
||||
}
|
||||
|
||||
function renderWidget(mode, spec) {
|
||||
var canvas = spec.canvas;
|
||||
var alt = spec.altContainer;
|
||||
applyPanelLayout(canvas, alt, mode);
|
||||
if (mode === 'neo-bars' && canvas) {
|
||||
if (alt) alt.innerHTML = '';
|
||||
canvas.style.display = '';
|
||||
renderBars(canvas, spec.labels, spec.values, spec.chartOpts || { key: spec.key });
|
||||
return;
|
||||
}
|
||||
if (mode === 'neo-rings' && canvas) {
|
||||
if (alt) alt.innerHTML = '';
|
||||
canvas.style.display = '';
|
||||
renderRings(canvas, spec.labels, spec.values, spec.key, spec.title);
|
||||
return;
|
||||
}
|
||||
if (canvas) canvas.style.display = 'none';
|
||||
if (!alt) return;
|
||||
if (mode === 'neo-equalizer') renderEqualizer(alt, spec.rows, spec.eqClass);
|
||||
else if (mode === 'neo-cards') renderCards(alt, spec.rows);
|
||||
else if (mode === 'neo-table') renderTable(alt, spec.rows);
|
||||
else {
|
||||
if (canvas) { canvas.style.display = ''; renderBars(canvas, spec.labels, spec.values, spec.chartOpts || { key: spec.key }); }
|
||||
}
|
||||
}
|
||||
|
||||
global.VizEngine = {
|
||||
MODES: MODES,
|
||||
renderWidget: renderWidget,
|
||||
destroyAll: function () { Object.keys(charts).forEach(destroy); },
|
||||
renderSentiment: function (mode, canvas, alt, stats, widgetMode) {
|
||||
var m = widgetMode || mode;
|
||||
var files = stats.nas_files || [];
|
||||
var counts = { positive: 0, neutral: 0, negative: 0 };
|
||||
files.forEach(function (f) {
|
||||
var s = (f.sentiment_label || 'neutral').toLowerCase();
|
||||
if (counts[s] !== undefined) counts[s]++;
|
||||
});
|
||||
if (!files.length) counts.neutral = 1;
|
||||
renderWidget(m, {
|
||||
canvas: canvas, altContainer: alt, key: 'sentiment',
|
||||
labels: ['Positief', 'Neutraal', 'Negatief'],
|
||||
values: [counts.positive, counts.neutral, counts.negative],
|
||||
title: 'NAS sentiment',
|
||||
rows: [
|
||||
{ label: 'Positief', value: counts.positive, raw: counts.positive },
|
||||
{ label: 'Neutraal', value: counts.neutral, raw: counts.neutral },
|
||||
{ label: 'Negatief', value: counts.negative, raw: counts.negative },
|
||||
],
|
||||
eqClass: 'hm-eq-purple',
|
||||
});
|
||||
},
|
||||
renderAgents: function (mode, canvas, alt, stats, widgetMode) {
|
||||
var m = widgetMode || mode;
|
||||
var map = {};
|
||||
(stats.recent_events || []).forEach(function (e) {
|
||||
var a = e.agent_name || 'other';
|
||||
map[a] = (map[a] || 0) + 1;
|
||||
});
|
||||
var labels = Object.keys(map);
|
||||
var values = labels.map(function (k) { return map[k]; });
|
||||
if (!labels.length) { labels = ['geen']; values = [0]; }
|
||||
renderWidget(m, {
|
||||
canvas: canvas, altContainer: alt, key: 'agents',
|
||||
labels: labels, values: values, title: 'Agent activiteit',
|
||||
chartOpts: { key: 'agents', color: colors().gold },
|
||||
rows: labels.map(function (l, i) { return { label: l, value: values[i], raw: values[i] }; }),
|
||||
eqClass: 'hm-eq-lime',
|
||||
});
|
||||
},
|
||||
renderFromRows: function (mode, canvas, alt, key, title, rows, eqClass, chartColor) {
|
||||
var labels = rows.map(function (r) { return r.label; });
|
||||
var values = rows.map(function (r) { return Number(r.raw) || 0; });
|
||||
renderWidget(mode, {
|
||||
canvas: canvas, altContainer: alt, key: key,
|
||||
labels: labels, values: values, title: title,
|
||||
chartOpts: { key: key, color: chartColor || colors().cyan },
|
||||
rows: rows,
|
||||
eqClass: eqClass || 'hm-eq-cyan',
|
||||
});
|
||||
},
|
||||
};
|
||||
})(window);
|
||||
+221
-49
@@ -1,8 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=3" />
|
||||
<script src="/static/js/agent-avatars.js?v=3"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
|
||||
@@ -11,6 +14,15 @@
|
||||
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
|
||||
</header>
|
||||
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
|
||||
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
|
||||
<div id="viz-agents-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
|
||||
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
|
||||
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
|
||||
@@ -19,6 +31,10 @@
|
||||
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
|
||||
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
|
||||
</button>
|
||||
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
|
||||
<span class="vtab-icon">✓</span><span>Goedkeuringen</span>
|
||||
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="vtabs-content">
|
||||
@@ -27,54 +43,41 @@
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:80%"><div class="hm-neo-ring-inner">🤖</div></div><div><div class="hm-neo-kpi-label">Agents</div><div class="hm-neo-kpi-sub">geregistreerd</div></div></div><div class="hm-neo-kpi-value" x-text="souls.length">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:70%"><div class="hm-neo-ring-inner">⚡</div></div><div><div class="hm-neo-kpi-label">Events</div><div class="hm-neo-kpi-sub">totaal gelogd</div></div></div><div class="hm-neo-kpi-value" x-text="totalEvents">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:50%"><div class="hm-neo-ring-inner">✓</div></div><div><div class="hm-neo-kpi-label">Actief</div><div class="hm-neo-kpi-sub">agents online</div></div></div><div class="hm-neo-kpi-value" x-text="activeCount">0</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">orchestrator</div></div></div><div class="hm-neo-kpi-value">CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-top"><div class="hm-neo-ring" style="--ring-color:#ffd700;--ring-pct:90%"><div class="hm-neo-ring-inner">👑</div></div><div><div class="hm-neo-kpi-label">Herman</div><div class="hm-neo-kpi-sub">Co-CEO · takenverdeler</div></div></div><div class="hm-neo-kpi-value" style="font-size:1.1rem">Co-CEO</div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="agents-neo-grid">
|
||||
<template x-for="s in souls" :key="s.agent_key">
|
||||
<article class="agent-soul-card" :class="{ selected: selected?.agent_key === s.agent_key }" @click="select(s.agent_key)">
|
||||
<div class="hm-neo-kpi-top">
|
||||
<div class="agent-avatar-wrap">
|
||||
<div class="agent-avatar" :style="'--av-color:' + (s.avatar_color || '#00e5ff')">
|
||||
<div class="agent-face" :class="[avatarStyle(s.agent_key), 'mood-' + (s.avatar_mood || 'happy')]">
|
||||
<span class="hair" x-show="hasHair(s.agent_key)"></span>
|
||||
<span class="glasses" x-show="hasGlasses(s.agent_key)"></span>
|
||||
<span class="eye left"></span>
|
||||
<span class="eye right"></span>
|
||||
<span class="mouth"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="agent-status-dot" x-show="s.is_active !== false"></span>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
|
||||
<span class="agent-tag" x-text="s.agent_key"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="agent-role-desc" x-text="s.responsibilities || 'Geen beschrijving'"></p>
|
||||
<div class="agent-char-wrap" :class="s.agent_key"
|
||||
:data-agent-avatar="s.agent_key"
|
||||
:data-agent-color="s.avatar_color || '#1e3a5f'"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<div class="hm-neo-kpi-sub" x-text="s.role_title"></div>
|
||||
<span class="agent-status-pill" :class="statusClass(s)">
|
||||
<span class="dot"></span>
|
||||
<span x-text="statusLabel(s)"></span>
|
||||
</span>
|
||||
<p class="agent-current-task" x-text="s.current_task || 'Wacht op taak…'"></p>
|
||||
<p class="agent-role-desc" x-text="(s.responsibilities || '').substring(0,120)"></p>
|
||||
<div class="agent-meta-row">
|
||||
<small x-text="(s.event_count||0) + ' events'"></small>
|
||||
<small x-text="s.last_event_at ? 'Laatst: '+s.last_event_at.substring(0,10) : 'Nog geen activiteit'"></small>
|
||||
<button type="button" class="agent-events-link"
|
||||
@click.stop="openEvents(s.agent_key, s.display_name)"
|
||||
x-text="(s.event_count||0) + ' events'"
|
||||
:title="'Bekijk wat ' + s.display_name + ' heeft gedaan'"></button>
|
||||
<span class="agent-tag" x-text="s.agent_key"></span>
|
||||
</div>
|
||||
<div class="hm-eq" style="margin-top:0.5rem"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="agent-soul-editor" x-show="selected" x-cloak>
|
||||
<div class="agent-editor-header">
|
||||
<div class="agent-avatar-wrap">
|
||||
<div class="agent-avatar" :style="'--av-color:' + (selected?.avatar_color || '#00e5ff')">
|
||||
<div class="agent-face" :class="[avatarStyle(selected?.agent_key), 'mood-' + (selected?.avatar_mood || 'happy')]">
|
||||
<span class="hair" x-show="hasHair(selected?.agent_key)"></span>
|
||||
<span class="glasses" x-show="hasGlasses(selected?.agent_key)"></span>
|
||||
<span class="eye left"></span>
|
||||
<span class="eye right"></span>
|
||||
<span class="mouth"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-char-wrap large" :class="selected?.agent_key"
|
||||
:data-agent-avatar="selected?.agent_key"
|
||||
:data-agent-color="selected?.avatar_color || '#1e3a5f'"
|
||||
x-effect="selected && $nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, selected.agent_key, selected.avatar_color))"></div>
|
||||
<div style="flex:1">
|
||||
<h2><span x-text="selected?.display_name"></span> — <span x-text="selected?.role_title"></span></h2>
|
||||
<p style="margin:0.25rem 0 0;color:#94a3b8;font-size:0.85rem" x-text="selected?.responsibilities"></p>
|
||||
@@ -87,9 +90,16 @@
|
||||
<label>Rol titel<input class="form-input" x-model="form.role_title"></label>
|
||||
<label>Functie & werkzaamheden<textarea class="form-input" rows="3" x-model="form.responsibilities"></textarea></label>
|
||||
<label>Soul.md (karakter, tone of voice, regels)<textarea x-model="form.soul_md"></textarea></label>
|
||||
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-top:1rem">Recente activiteit</h3>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
|
||||
<h3 style="font-size:0.75rem;text-transform:uppercase;color:#64748b;margin:0">Recente activiteit</h3>
|
||||
<button type="button" class="agent-events-link" x-show="selected" @click="openEvents(selected.agent_key, selected.display_name)">Alle events →</button>
|
||||
</div>
|
||||
<template x-for="ev in (selected?.recent_events||[])" :key="ev.id">
|
||||
<div class="hub-feed-item"><span x-text="ev.created_at?.substring(0,16)"></span> <span x-text="ev.title||ev.event_type"></span></div>
|
||||
<button type="button" class="hub-feed-item agent-event-clickable" @click="openEvents(selected.agent_key, selected.display_name, ev.id)">
|
||||
<span class="agent-event-time" x-text="formatEventTime(ev.created_at)"></span>
|
||||
<span class="agent-event-title" x-text="ev.title||ev.event_type"></span>
|
||||
<span class="badge badge-sm" x-text="ev.status||'—'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<p x-show="!(selected?.recent_events||[]).length" class="muted">Nog geen events voor deze agent.</p>
|
||||
</div>
|
||||
@@ -98,7 +108,7 @@
|
||||
<section x-show="tab==='mesh'" x-cloak>
|
||||
<div class="panel">
|
||||
<h2 style="margin-top:0;">Netwerk</h2>
|
||||
<p class="page-tip" style="margin-top:.25rem;">Communication mesh met Herman als centrale Co-CEO hub.</p>
|
||||
<p class="page-tip" style="margin-top:.25rem;">Agents onderaan → Herman (Co-CEO) → CEO & CTO. Pulse = actieve communicatie.</p>
|
||||
<div class="mesh-wrap">
|
||||
<svg id="agents-mesh-canvas" class="mesh-svg" viewBox="0 0 1100 640"></svg>
|
||||
</div>
|
||||
@@ -110,11 +120,82 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="tab==='approvals'" x-cloak x-data="approvalsPanel()" x-init="load()">
|
||||
<div class="panel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<h2 style="margin:0;">Approval queue</h2>
|
||||
<p class="page-tip">Agents dienen queries in → jij keurt goed → pas daarna mag de actie uitgevoerd worden.</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-pulse" @click="load()">↻ Verversen</button>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Agent</th><th>Actie</th><th>Titel</th><th>Status</th><th>Tijd</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="r in items" :key="r.id">
|
||||
<tr>
|
||||
<td x-text="r.id"></td>
|
||||
<td x-text="r.agent_key"></td>
|
||||
<td x-text="r.action_type"></td>
|
||||
<td x-text="r.title"></td>
|
||||
<td><span class="badge" :class="r.status==='pending'?'badge-warn':'badge-positive'" x-text="r.status"></span></td>
|
||||
<td x-text="(r.created_at||'').substring(0,16).replace('T',' ')"></td>
|
||||
<td>
|
||||
<template x-if="r.status==='pending'">
|
||||
<span>
|
||||
<button class="btn btn-sm btn-approve" @click="approve(r.id)">✓</button>
|
||||
<button class="btn btn-sm btn-reject" @click="reject(r.id)">✗</button>
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p x-show="!items.length" class="muted">Geen approval requests.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-events-backdrop" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
|
||||
<aside class="agent-events-drawer" :class="{ open: eventsOpen }" x-cloak>
|
||||
<header class="agent-events-head">
|
||||
<div>
|
||||
<h3 x-text="eventsAgentName || 'Agent'"></h3>
|
||||
<p class="muted" x-text="eventsList.length + ' activiteiten gelogd'"></p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm" @click="closeEvents()">✕</button>
|
||||
</header>
|
||||
<div class="agent-events-body" x-show="!eventsLoading">
|
||||
<template x-for="ev in eventsList" :key="ev.id">
|
||||
<article class="agent-event-card" :class="{ expanded: selectedEvent?.id === ev.id }" @click="toggleEventDetail(ev)">
|
||||
<div class="agent-event-card-top">
|
||||
<time x-text="formatEventTime(ev.created_at)"></time>
|
||||
<span class="badge badge-sm" :class="eventStatusClass(ev.status)" x-text="ev.status || 'completed'"></span>
|
||||
</div>
|
||||
<strong x-text="ev.title || ev.event_type"></strong>
|
||||
<div class="agent-event-meta">
|
||||
<span x-text="ev.event_type"></span>
|
||||
<span x-show="ev.channel" x-text="'· ' + ev.channel"></span>
|
||||
</div>
|
||||
<div class="agent-event-detail" x-show="selectedEvent?.id === ev.id" x-cloak>
|
||||
<p x-show="ev.body" x-text="ev.body"></p>
|
||||
<pre x-show="!ev.body && ev.metadata && Object.keys(ev.metadata||{}).length" x-text="JSON.stringify(ev.metadata, null, 2)"></pre>
|
||||
<p x-show="!ev.body && !(ev.metadata && Object.keys(ev.metadata||{}).length)" class="muted">Geen extra details voor dit event.</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p x-show="!eventsList.length && !eventsLoading" class="muted" style="padding:1rem">Nog geen events voor deze agent.</p>
|
||||
</div>
|
||||
<p x-show="eventsLoading" class="muted" style="padding:1.5rem">Events laden…</p>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard-layout.js?v=2"></script>
|
||||
<script src="/static/js/page-viz.js?v=1"></script>
|
||||
<script src="/static/js/agents-mesh.js"></script>
|
||||
<script>
|
||||
function agentsSoulHub() {
|
||||
@@ -123,22 +204,67 @@ function agentsSoulHub() {
|
||||
tabIndicatorTop: 0,
|
||||
tabIndicatorH: 40,
|
||||
meshLoaded: false,
|
||||
pendingCount: 0,
|
||||
souls: [], selected: null, form: {}, saving: false,
|
||||
eventsOpen: false, eventsAgentKey: null, eventsAgentName: '', eventsList: [], eventsLoading: false, selectedEvent: null,
|
||||
get totalEvents() { return this.souls.reduce((a,s) => a + (s.event_count||0), 0); },
|
||||
get activeCount() { return this.souls.filter(s => s.is_active !== false).length; },
|
||||
avatarStyle(key) {
|
||||
const m = {
|
||||
herman: 'style-male-glasses', design: 'style-female-long', marketing: 'style-female-bob',
|
||||
retail: 'style-male-short', research: 'style-male-glasses', sales: 'style-male-curl',
|
||||
hermes: 'style-male-short', finance: 'style-male-glasses', ops: 'style-male-short',
|
||||
quality: 'style-female-bob', logistics: 'style-male-curl', legal: 'style-male-glasses',
|
||||
};
|
||||
return m[key] || 'style-male-short';
|
||||
statusClass(s) {
|
||||
const st = (s.current_status || '').toLowerCase();
|
||||
if (st === 'needs_approval' || st === 'pending') return 'busy';
|
||||
if (st === 'error' || st === 'rejected') return 'error';
|
||||
if (st === 'completed' || (s.events_6h > 0)) return 'active';
|
||||
return 'idle';
|
||||
},
|
||||
formatEventTime(ts) {
|
||||
return (ts || '').substring(0, 16).replace('T', ' ');
|
||||
},
|
||||
eventStatusClass(status) {
|
||||
const s = (status || '').toLowerCase();
|
||||
if (s === 'pending' || s === 'needs_approval') return 'badge-warn';
|
||||
if (s === 'error' || s === 'rejected') return 'badge-negative';
|
||||
if (s === 'approved' || s === 'completed') return 'badge-positive';
|
||||
return '';
|
||||
},
|
||||
async openEvents(key, name, focusId) {
|
||||
if (!key) return;
|
||||
this.eventsAgentKey = key;
|
||||
this.eventsAgentName = name || key;
|
||||
this.eventsOpen = true;
|
||||
this.eventsLoading = true;
|
||||
this.selectedEvent = null;
|
||||
try {
|
||||
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=50').then(x => x.json());
|
||||
this.eventsList = r.items || [];
|
||||
if (focusId) {
|
||||
const hit = this.eventsList.find(e => e.id === focusId);
|
||||
if (hit) this.selectedEvent = hit;
|
||||
}
|
||||
} catch (e) { this.eventsList = []; }
|
||||
this.eventsLoading = false;
|
||||
},
|
||||
closeEvents() {
|
||||
this.eventsOpen = false;
|
||||
this.selectedEvent = null;
|
||||
},
|
||||
toggleEventDetail(ev) {
|
||||
this.selectedEvent = this.selectedEvent?.id === ev.id ? null : ev;
|
||||
},
|
||||
statusLabel(s) {
|
||||
const st = (s.current_status || '').toLowerCase();
|
||||
if (st === 'needs_approval') return 'Wacht op goedkeuring';
|
||||
if (st === 'pending') return 'In wachtrij';
|
||||
if (st === 'completed') return 'Klaar';
|
||||
if (st === 'error') return 'Fout';
|
||||
if (s.current_task) return 'Bezig';
|
||||
return 'Standby';
|
||||
},
|
||||
hasHair(key) { return !!key; },
|
||||
hasGlasses(key) { return ['herman','research','finance','legal'].includes(key); },
|
||||
async init() {
|
||||
await this.load();
|
||||
await this.loadPendingCount();
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('agents', (mode) => this.renderViz(mode));
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const activeBtn = this.$refs.vtabsNav?.querySelector('.vtab-btn.active');
|
||||
if (activeBtn) this.updateTabIndicator(activeBtn);
|
||||
@@ -159,6 +285,13 @@ function agentsSoulHub() {
|
||||
url.searchParams.set('tab', next);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
if (next === 'mesh') this.mountMesh();
|
||||
if (next === 'approvals') this.loadPendingCount();
|
||||
},
|
||||
async loadPendingCount() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending').then(x => x.json());
|
||||
this.pendingCount = (r.items || []).length;
|
||||
} catch (e) { this.pendingCount = 0; }
|
||||
},
|
||||
mountMesh() {
|
||||
if (this.meshLoaded || !window.AgentsMesh) return;
|
||||
@@ -169,6 +302,22 @@ function agentsSoulHub() {
|
||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
||||
this.souls = r.items || [];
|
||||
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
|
||||
},
|
||||
renderViz(mode) {
|
||||
if (!window.VizEngine) return;
|
||||
const rows = (this.souls || []).map(s => ({
|
||||
label: s.agent_key,
|
||||
value: s.event_count || 0,
|
||||
raw: s.event_count || 0,
|
||||
})).sort((a, b) => b.raw - a.raw).slice(0, 12);
|
||||
if (!rows.length) rows.push({ label: 'geen', value: 0, raw: 0 });
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-agents-viz'),
|
||||
document.getElementById('viz-agents-alt'),
|
||||
'agents-page', 'Events per agent', rows, 'hm-eq-lime'
|
||||
);
|
||||
},
|
||||
async select(key) {
|
||||
const r = await fetch('/api/agents/souls/' + key).then(x => x.json());
|
||||
@@ -188,5 +337,28 @@ function agentsSoulHub() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function approvalsPanel() {
|
||||
return {
|
||||
items: [],
|
||||
async load() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?limit=50').then(x => x.json());
|
||||
this.items = r.items || [];
|
||||
} catch (e) { this.items = []; }
|
||||
},
|
||||
async approve(id) {
|
||||
await Cockpit.api('/api/agents/approvals/' + id + '/approve', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Goedgekeurd — agent mag query uitvoeren', 'success');
|
||||
await this.load();
|
||||
},
|
||||
async reject(id) {
|
||||
const reason = prompt('Reden afwijzing (optioneel):') || '';
|
||||
await Cockpit.api('/api/agents/approvals/' + id + '/reject', { method: 'POST', body: JSON.stringify({ reason }) });
|
||||
Cockpit.toast('Afgewezen', 'info');
|
||||
await this.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -71,5 +71,5 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
|
||||
<script src="/static/js/analytics.js?v=1"></script>
|
||||
<script src="/static/js/analytics.js?v=2"></script>
|
||||
{% endblock %}
|
||||
|
||||
+45
-27
@@ -17,6 +17,7 @@
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
@@ -30,46 +31,48 @@
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}">Herman</a>
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" data-i18n="nav.herman">Herman</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">CRM</div>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}">Clients</a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}">Deals</a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}">Products</a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}">Suppliers</a>
|
||||
<div class="app-sidebar-label" data-i18n="nav.crm">CRM</div>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" data-i18n="nav.clients">Klanten</a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" data-i18n="nav.deals">Deals</a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" data-i18n="nav.products">Producten</a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" data-i18n="nav.suppliers">Leveranciers</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Intel</div>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}">Beurs Live</a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}">Retail</a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}">Browser</a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}">Documents</a>
|
||||
<div class="app-sidebar-label" data-i18n="nav.intel">Intel</div>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" data-i18n="nav.beurs">Beurs Live</a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" data-i18n="nav.retail">Retail</a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" data-i18n="nav.browser">Browser</a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" data-i18n="nav.documents">Documenten</a>
|
||||
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" data-i18n="nav.projects">Projecten</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Hermes</div>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}">Telegram</a>
|
||||
<div class="app-sidebar-label" data-i18n="nav.hermes">Hermes</div>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" data-i18n="nav.telegram">Telegram</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">Agents</div>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}">Agents</a>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}">Marketing</a>
|
||||
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}">Chat</a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}">Studio</a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}">Packaging</a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}">Reports</a>
|
||||
<div class="app-sidebar-label" data-i18n="nav.agents">Agents</div>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" data-i18n="nav.marketing">Marketing</a>
|
||||
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}" data-i18n="nav.chat">Chat</a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" data-i18n="nav.studio">Studio</a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" data-i18n="nav.packaging">Packaging</a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" data-i18n="nav.reports">Rapporten</a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label">System</div>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}">Analytics</a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}">Voice</a>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}">IT Ops</a>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}">Settings</a>
|
||||
<div class="app-sidebar-label" data-i18n="nav.system">Systeem</div>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" data-i18n="nav.analytics">Analytics</a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" data-i18n="nav.voice">Voice</a>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" data-i18n="nav.ops">IT Ops</a>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" data-i18n="nav.settings">Instellingen</a>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="nav-pill nav-pill-settings" data-i18n="nav.gitea">Gitea</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -79,6 +82,11 @@
|
||||
<header class="app-topbar">
|
||||
<button type="button" class="mobile-menu-btn" @click="navOpen = !navOpen" aria-label="Menu openen">☰</button>
|
||||
<span class="page-tip">Foodlinkk</span>
|
||||
<select class="form-input" style="width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
|
||||
x-model="locale" @change="setLocale(locale)" :title="$t ? $t('common.language') : 'Taal'">
|
||||
<option value="nl">NL</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
||||
</header>
|
||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||
@@ -91,11 +99,15 @@
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/cockpit.js"></script>
|
||||
<script src="/static/js/i18n.js?v=2"></script>
|
||||
<script src="/static/js/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/global-viz.js?v=1"></script>
|
||||
<script src="/static/js/live-pulse.js?v=2"></script>
|
||||
<script>
|
||||
function appShell() {
|
||||
return {
|
||||
locale: 'nl',
|
||||
drawerOpen: false,
|
||||
navOpen: false,
|
||||
showInstall: false,
|
||||
@@ -103,10 +115,16 @@
|
||||
clockDate: '—',
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
initApp() {
|
||||
async initApp() {
|
||||
if (window.I18n) await window.I18n.init();
|
||||
this.locale = window.I18n ? window.I18n.locale() : 'nl';
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
},
|
||||
async setLocale(loc) {
|
||||
if (window.I18n) await window.I18n.setLocale(loc);
|
||||
this.locale = loc;
|
||||
},
|
||||
async initClock() {
|
||||
try {
|
||||
const r = await fetch('/api/server-time').then(x => x.json());
|
||||
|
||||
+527
-42
@@ -1,54 +1,539 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="clientsCrud()">
|
||||
<header class="page-header"><h1>Clients</h1>
|
||||
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
|
||||
<button class="btn btn-primary" @click="openModal()">Add client</button>
|
||||
<div class="kanban">
|
||||
{% set stages = ['intake','discovery','proposal','active','churned'] %}
|
||||
{% for st in stages %}
|
||||
<div class="kanban-col"><h3>{{ st }}</h3>
|
||||
{% for c in clients if c.stage == st %}
|
||||
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
|
||||
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
|
||||
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
|
||||
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
|
||||
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
|
||||
<input class="form-input" placeholder="Name" x-model="form.name" />
|
||||
<input class="form-input" placeholder="Email" x-model="form.email" />
|
||||
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
|
||||
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
|
||||
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
|
||||
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 x-text="$t('clients.title')">CRM · Klanten</h1>
|
||||
<p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
|
||||
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
|
||||
<a class="btn btn-sm" href="/deals">Deals →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="clients-kpi-row">
|
||||
<div class="clients-kpi"><span>Totaal klanten</span><strong x-text="stats.total||0"></strong></div>
|
||||
<div class="clients-kpi"><span>Actief</span><strong x-text="stats.active||0"></strong></div>
|
||||
<div class="clients-kpi"><span>MRR schatting</span><strong x-text="fmtEur(stats.mrr_total)"></strong></div>
|
||||
<div class="clients-kpi"><span>Pipeline</span><strong x-text="fmtEur(stats.pipeline_value)"></strong></div>
|
||||
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
|
||||
|
||||
<div class="clients-tabs">
|
||||
<button class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'" x-text="$t('clients.tab_kanban')">Kanban</button>
|
||||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('clients.tab_list')">Lijst</button>
|
||||
<button class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; searchStores()" x-text="$t('clients.tab_stores')">Supermarkten</button>
|
||||
<div class="clients-tab-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openClientModal()" x-text="'+ ' + $t('clients.new')">+ Nieuwe klant</button>
|
||||
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div x-show="tab==='kanban'" x-cloak>
|
||||
<div class="clients-kanban">
|
||||
<template x-for="st in stages" :key="st.id">
|
||||
<div class="clients-kanban-col">
|
||||
<h3><span x-text="st.label"></span> <span class="count" x-text="stageCount(st.id)"></span></h3>
|
||||
<template x-for="c in clientsByStage(st.id)" :key="c.id">
|
||||
<div class="clients-card" :class="'stage-'+c.stage" @click="openDetail(c)">
|
||||
<strong x-text="c.name"></strong>
|
||||
<div class="clients-card-meta">
|
||||
<span x-show="c.contact" x-text="c.contact"></span>
|
||||
<span x-show="c.sector"> · <span x-text="c.sector"></span></span>
|
||||
</div>
|
||||
<div class="clients-card-meta" x-show="c.email" x-text="c.email"></div>
|
||||
<div class="clients-card-tags">
|
||||
<span class="clients-tag mrr" x-show="c.mrr_estimate" x-text="'€'+Number(c.mrr_estimate).toLocaleString('nl-NL')+'/mnd'"></span>
|
||||
<span class="clients-tag" x-show="c.store_count" x-text="c.store_count+' filialen'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div x-show="tab==='list'" x-cloak>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" placeholder="Zoek naam, contact, e-mail…" x-model="filterQ" @input="applyFilter()" />
|
||||
<select x-model="filterStage" @change="applyFilter()">
|
||||
<option value="">Alle stages</option>
|
||||
<template x-for="st in stages" :key="'f'+st.id"><option :value="st.id" x-text="st.label"></option></template>
|
||||
</select>
|
||||
<select x-model="filterSector" @change="applyFilter()">
|
||||
<option value="">Alle sectoren</option>
|
||||
<template x-for="s in sectorOptions" :key="s"><option :value="s" x-text="s"></option></template>
|
||||
</select>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Naam</th><th>Contact</th><th>E-mail</th><th>Sector</th><th>Stage</th><th>MRR</th><th>Filialen</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<template x-for="c in filtered" :key="'l'+c.id">
|
||||
<tr class="clickable-row" @click="openDetail(c)">
|
||||
<td><strong x-text="c.name"></strong></td>
|
||||
<td x-text="c.contact||'—'"></td>
|
||||
<td x-text="c.email||'—'"></td>
|
||||
<td x-text="c.sector||'—'"></td>
|
||||
<td><span class="badge" x-text="stageLabel(c.stage)"></span></td>
|
||||
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
|
||||
<td x-text="c.store_count||0"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm" @click.stop="editClient(c)">Bewerk</button>
|
||||
<button class="btn btn-sm btn-reject" @click.stop="removeClient(c.id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="clients-empty" x-show="!filtered.length">Geen klanten gevonden.</p>
|
||||
</div>
|
||||
|
||||
<!-- Supermarkten -->
|
||||
<div x-show="tab==='stores'" x-cloak>
|
||||
<p class="page-tip" style="margin-bottom:0.75rem">Zoek een filiaal, vul ontbrekende data aan (contact, halal, manager) en koppel aan een klant.</p>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" placeholder="Zoek filiaal, keten, stad…" x-model="storeQ" @keyup.enter="searchStores()" />
|
||||
<select x-model="storeChain" @change="searchStores()">
|
||||
<option value="">Alle ketens</option>
|
||||
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
|
||||
</select>
|
||||
<select x-model="storeProvince" @change="searchStores()">
|
||||
<option value="">Alle provincies</option>
|
||||
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-pulse" @click="searchStores()" :disabled="storeBusy">Zoeken</button>
|
||||
</div>
|
||||
<div class="store-search-panel">
|
||||
<div>
|
||||
<div class="store-list">
|
||||
<template x-for="s in storeResults" :key="s.id">
|
||||
<div class="store-list-item" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
|
||||
<strong x-text="s.chain+' · '+s.name"></strong>
|
||||
<div class="clients-card-meta" x-text="(s.city||'') + (s.province ? ', '+s.province : '')"></div>
|
||||
<div class="clients-card-tags">
|
||||
<span class="clients-tag" x-show="s.partnership_status && s.partnership_status!=='none'" x-text="s.partnership_status"></span>
|
||||
<span class="clients-tag" x-show="s.halal_certified">Halal ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
|
||||
</div>
|
||||
<p class="muted" style="font-size:0.72rem;margin-top:0.35rem" x-text="storeResults.length+' filialen · max 80'"></p>
|
||||
</div>
|
||||
<div class="store-edit-panel" x-show="selectedStore">
|
||||
<h3 x-text="(selectedStore?.chain||'') + ' · ' + (selectedStore?.name||'')"></h3>
|
||||
<div class="clients-form-grid">
|
||||
<label class="full"><span class="filter-label">Adres</span>
|
||||
<input class="form-input" x-model="storeForm.address" /></label>
|
||||
<label><span class="filter-label">Postcode</span>
|
||||
<input class="form-input" x-model="storeForm.postcode" /></label>
|
||||
<label><span class="filter-label">Stad</span>
|
||||
<input class="form-input" x-model="storeForm.city" /></label>
|
||||
<label><span class="filter-label">Provincie</span>
|
||||
<input class="form-input" x-model="storeForm.province" /></label>
|
||||
<label><span class="filter-label">Telefoon</span>
|
||||
<input class="form-input" x-model="storeForm.phone" /></label>
|
||||
<label><span class="filter-label">E-mail</span>
|
||||
<input class="form-input" x-model="storeForm.email" /></label>
|
||||
<label><span class="filter-label">Website</span>
|
||||
<input class="form-input" x-model="storeForm.website" /></label>
|
||||
<label><span class="filter-label">Manager</span>
|
||||
<input class="form-input" x-model="storeForm.manager_name" /></label>
|
||||
<label><span class="filter-label">Partnership</span>
|
||||
<select class="form-input" x-model="storeForm.partnership_status">
|
||||
<option value="none">Geen</option><option value="prospect">Prospect</option>
|
||||
<option value="active">Actief</option><option value="paused">Gepauzeerd</option>
|
||||
</select></label>
|
||||
<label><span class="filter-label">Halal certificerder</span>
|
||||
<input class="form-input" x-model="storeForm.halal_certifier" placeholder="HQC, OSM…" /></label>
|
||||
</div>
|
||||
<div class="store-checks">
|
||||
<label><input type="checkbox" x-model="storeForm.halal_certified" /> Halal gecertificeerd</label>
|
||||
<label><input type="checkbox" x-model="storeForm.has_halal_section" /> Halal schap</label>
|
||||
</div>
|
||||
<div class="clients-form-grid" style="margin-top:0.75rem">
|
||||
<label class="full"><span class="filter-label">Koppel aan klant</span>
|
||||
<select class="form-input" x-model="linkForm.client_id">
|
||||
<option value="">— Selecteer klant —</option>
|
||||
<template x-for="c in clients" :key="'lc'+c.id">
|
||||
<option :value="c.id" x-text="c.name + ' ('+stageLabel(c.stage)+')'"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<label><span class="filter-label">Relatie</span>
|
||||
<select class="form-input" x-model="linkForm.relationship_type">
|
||||
<option value="prospect">Prospect</option><option value="partner">Partner</option>
|
||||
<option value="supplier">Leverancier</option><option value="competitor">Concurrent</option>
|
||||
</select></label>
|
||||
<label><span class="filter-label">Notities koppeling</span>
|
||||
<input class="form-input" x-model="linkForm.notes" placeholder="Optioneel" /></label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button class="btn btn-primary btn-sm" @click="saveStore()" :disabled="storeBusy">Opslaan filiaal</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="linkStore()" :disabled="!linkForm.client_id">Koppel klant</button>
|
||||
<a class="btn btn-sm" :href="'/retail?store='+selectedStore?.id" target="_blank">Retail 360 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="store-edit-panel clients-empty" x-show="!selectedStore && !storeBusy">
|
||||
Selecteer een filiaal links om data aan te vullen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client modal -->
|
||||
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<h3 x-text="clientForm.id ? 'Klant bewerken' : 'Nieuwe klant'"></h3>
|
||||
<div class="clients-form-grid">
|
||||
<label class="full"><span class="filter-label">Bedrijfsnaam *</span>
|
||||
<input class="form-input" x-model="clientForm.name" placeholder="Chai N Masala B.V." /></label>
|
||||
<label><span class="filter-label">Contactpersoon</span>
|
||||
<input class="form-input" x-model="clientForm.contact" /></label>
|
||||
<label><span class="filter-label">E-mail</span>
|
||||
<input class="form-input" type="email" x-model="clientForm.email" /></label>
|
||||
<label><span class="filter-label">Sector</span>
|
||||
<select class="form-input" x-model="clientForm.sector">
|
||||
<option value="">— Kies sector —</option>
|
||||
<template x-for="s in sectorOptions" :key="'s'+s"><option :value="s" x-text="s"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label">Stage</span>
|
||||
<select class="form-input" x-model="clientForm.stage">
|
||||
<template x-for="st in stages" :key="'cs'+st.id"><option :value="st.id" x-text="st.label"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label">MRR schatting (€/mnd)</span>
|
||||
<input class="form-input" type="number" min="0" step="100" x-model="clientForm.mrr_estimate" /></label>
|
||||
<label class="full"><span class="filter-label">Notities</span>
|
||||
<textarea class="form-input" rows="4" x-model="clientForm.notes" placeholder="Context, afspraken, next steps…"></textarea></label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" @click="saveClient()" :disabled="!clientForm.name.trim()">Opslaan</button>
|
||||
<button class="btn btn-secondary" @click="clientModal=false">Annuleren</button>
|
||||
<button class="btn btn-reject" x-show="clientForm.id" @click="removeClient(clientForm.id); clientModal=false">Verwijderen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store create modal -->
|
||||
<div class="modal" :class="{ open: storeCreateModal }" @click.self="storeCreateModal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<h3>Nieuw filiaal toevoegen</h3>
|
||||
<div class="clients-form-grid">
|
||||
<label><span class="filter-label">Keten *</span>
|
||||
<input class="form-input" x-model="newStore.chain" list="chain-list" placeholder="Jumbo, AH, Lidl…" /></label>
|
||||
<datalist id="chain-list"><template x-for="ch in chains" :key="'dl'+ch"><option :value="ch"></option></template></datalist>
|
||||
<label><span class="filter-label">Naam *</span>
|
||||
<input class="form-input" x-model="newStore.name" placeholder="Jumbo Bos en Lommer" /></label>
|
||||
<label class="full"><span class="filter-label">Adres *</span>
|
||||
<input class="form-input" x-model="newStore.address" /></label>
|
||||
<label><span class="filter-label">Postcode</span>
|
||||
<input class="form-input" x-model="newStore.postcode" /></label>
|
||||
<label><span class="filter-label">Stad *</span>
|
||||
<input class="form-input" x-model="newStore.city" /></label>
|
||||
<label><span class="filter-label">Provincie</span>
|
||||
<input class="form-input" x-model="newStore.province" /></label>
|
||||
<label><span class="filter-label">Telefoon</span>
|
||||
<input class="form-input" x-model="newStore.phone" /></label>
|
||||
<label><span class="filter-label">E-mail</span>
|
||||
<input class="form-input" x-model="newStore.email" /></label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" @click="createStore()" :disabled="!newStore.name||!newStore.chain||!newStore.city">Toevoegen</button>
|
||||
<button class="btn btn-secondary" @click="storeCreateModal=false">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail drawer -->
|
||||
<aside class="clients-drawer" :class="{ open: drawer }">
|
||||
<div class="clients-drawer-head">
|
||||
<div>
|
||||
<h2 x-text="detail?.client?.name||''"></h2>
|
||||
<p class="muted" x-text="stageLabel(detail?.client?.stage)+' · '+(detail?.client?.sector||'Geen sector')"></p>
|
||||
</div>
|
||||
<button class="drawer-close" @click="drawer=false">×</button>
|
||||
</div>
|
||||
<template x-if="detail?.client">
|
||||
<div>
|
||||
<div class="clients-card-meta">
|
||||
<div x-show="detail.client.contact"><strong>Contact:</strong> <span x-text="detail.client.contact"></span></div>
|
||||
<div x-show="detail.client.email"><strong>E-mail:</strong> <span x-text="detail.client.email"></span></div>
|
||||
<div x-show="detail.client.mrr_estimate"><strong>MRR:</strong> <span x-text="fmtEur(detail.client.mrr_estimate)"></span></div>
|
||||
<div x-show="detail.client.notes" style="margin-top:0.5rem;white-space:pre-wrap" x-text="detail.client.notes"></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:0.75rem">
|
||||
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
|
||||
<select class="form-input" style="width:auto" x-model="detail.client.stage" @change="quickStage(detail.client)">
|
||||
<template x-for="st in stages" :key="'ds'+st.id"><option :value="st.id" x-text="st.label"></option></template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="clients-drawer-section">
|
||||
<h4>Deals (<span x-text="(detail.deals||[]).length"></span>)</h4>
|
||||
<template x-for="d in detail.deals||[]" :key="d.id">
|
||||
<div class="clients-card" style="cursor:default">
|
||||
<strong x-text="d.title"></strong>
|
||||
<div class="clients-card-meta" x-text="fmtEur(d.value)+' · '+d.stage"></div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="muted" x-show="!(detail.deals||[]).length">Nog geen deals — <a href="/deals">voeg toe</a></p>
|
||||
</div>
|
||||
<div class="clients-drawer-section">
|
||||
<h4>Gekoppelde filialen (<span x-text="(detail.stores||[]).length"></span>)</h4>
|
||||
<template x-for="s in detail.stores||[]" :key="s.id">
|
||||
<div class="clients-card" style="cursor:default">
|
||||
<strong x-text="s.chain+' · '+s.name"></strong>
|
||||
<div class="clients-card-meta" x-text="s.city+(s.province?', '+s.province:'')"></div>
|
||||
<span class="clients-tag" x-text="s.relationship_type||s.partnership_status"></span>
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-sm" style="margin-top:0.5rem" @click="tab='stores'; drawer=false">+ Filiaal koppelen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function clientsCrud() {
|
||||
function clientsHub() {
|
||||
const STAGES = [
|
||||
{ id: 'intake', label: 'Intake' },
|
||||
{ id: 'discovery', label: 'Verkenning' },
|
||||
{ id: 'proposal', label: 'Voorstel' },
|
||||
{ id: 'active', label: 'Actief' },
|
||||
{ id: 'churned', label: 'Verloren' },
|
||||
];
|
||||
const SECTORS = [
|
||||
'Retail', 'Supermarkt', 'Groothandel', 'Horeca', 'Food service',
|
||||
'Spices & Tea', 'Kant-en-klaar', 'Import/Export', 'Private label', 'Overig',
|
||||
];
|
||||
return {
|
||||
modal: false, drawer: false, detail: '',
|
||||
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
|
||||
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
|
||||
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
|
||||
async save() {
|
||||
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
|
||||
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
tab: 'kanban',
|
||||
busy: false,
|
||||
storeBusy: false,
|
||||
clientModal: false,
|
||||
storeCreateModal: false,
|
||||
drawer: false,
|
||||
stages: STAGES,
|
||||
sectorOptions: SECTORS,
|
||||
stats: {},
|
||||
clients: [],
|
||||
filtered: [],
|
||||
filterQ: '',
|
||||
filterStage: '',
|
||||
filterSector: '',
|
||||
detail: null,
|
||||
clientForm: {},
|
||||
storeResults: [],
|
||||
selectedStore: null,
|
||||
storeForm: {},
|
||||
linkForm: { client_id: '', relationship_type: 'prospect', notes: '' },
|
||||
storeQ: '',
|
||||
storeChain: '',
|
||||
storeProvince: '',
|
||||
chains: [],
|
||||
provinces: [],
|
||||
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
|
||||
|
||||
fmtEur(v) {
|
||||
if (v == null || v === '') return '—';
|
||||
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
|
||||
},
|
||||
stageLabel(id) {
|
||||
return (STAGES.find(s => s.id === id) || {}).label || id || '—';
|
||||
},
|
||||
stageCount(id) { return this.clients.filter(c => c.stage === id).length; },
|
||||
clientsByStage(id) { return this.clients.filter(c => c.stage === id); },
|
||||
|
||||
async init() { await this.loadAll(); },
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
try {
|
||||
const [statsR, clientsR, filtersR] = await Promise.all([
|
||||
Cockpit.api('/clients/stats'),
|
||||
Cockpit.api('/clients'),
|
||||
fetch('/api/retail/filters').then(r => r.json()).catch(() => ({})),
|
||||
]);
|
||||
this.stats = statsR.stats || {};
|
||||
this.clients = clientsR.items || [];
|
||||
this.chains = (filtersR.chains || []).map(x => x.chain || x).filter(Boolean);
|
||||
this.provinces = (filtersR.provinces || []).map(x => x.province || x).filter(Boolean);
|
||||
this.applyFilter();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
applyFilter() {
|
||||
const q = (this.filterQ || '').toLowerCase();
|
||||
this.filtered = this.clients.filter(c => {
|
||||
if (this.filterStage && c.stage !== this.filterStage) return false;
|
||||
if (this.filterSector && c.sector !== this.filterSector) return false;
|
||||
if (!q) return true;
|
||||
return [c.name, c.contact, c.email, c.sector, c.notes].some(
|
||||
f => f && String(f).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
openClientModal() {
|
||||
this.clientForm = { id: null, name: '', contact: '', email: '', stage: 'intake', sector: '', mrr_estimate: '', notes: '' };
|
||||
this.clientModal = true;
|
||||
},
|
||||
editClient(c) {
|
||||
this.clientForm = {
|
||||
id: c.id, name: c.name || '', contact: c.contact || '', email: c.email || '',
|
||||
stage: c.stage || 'intake', sector: c.sector || '', mrr_estimate: c.mrr_estimate || '',
|
||||
notes: c.notes || '',
|
||||
};
|
||||
this.clientModal = true;
|
||||
this.drawer = false;
|
||||
},
|
||||
|
||||
async saveClient() {
|
||||
const body = {
|
||||
name: this.clientForm.name.trim(),
|
||||
contact: this.clientForm.contact || null,
|
||||
email: this.clientForm.email || null,
|
||||
stage: this.clientForm.stage,
|
||||
sector: this.clientForm.sector || null,
|
||||
mrr_estimate: this.clientForm.mrr_estimate ? parseFloat(this.clientForm.mrr_estimate) : null,
|
||||
notes: this.clientForm.notes || null,
|
||||
};
|
||||
try {
|
||||
if (this.clientForm.id) {
|
||||
await Cockpit.api('/clients/' + this.clientForm.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
Cockpit.toast('Klant opgeslagen', 'success');
|
||||
this.clientModal = false;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async removeClient(id) {
|
||||
if (!Cockpit.confirmDelete('Klant definitief verwijderen?')) return;
|
||||
try {
|
||||
await Cockpit.api('/clients/' + id, { method: 'DELETE' });
|
||||
Cockpit.toast('Klant verwijderd', 'info');
|
||||
this.drawer = false;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async openDetail(c) {
|
||||
try {
|
||||
const d = await Cockpit.api('/clients/' + c.id + '/detail');
|
||||
this.detail = d;
|
||||
this.drawer = true;
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async quickStage(c) {
|
||||
const body = {
|
||||
name: c.name, contact: c.contact, email: c.email, stage: c.stage,
|
||||
sector: c.sector, mrr_estimate: c.mrr_estimate, notes: c.notes,
|
||||
};
|
||||
try {
|
||||
await Cockpit.api('/clients/' + c.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
Cockpit.toast('Stage bijgewerkt', 'success');
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async searchStores() {
|
||||
this.storeBusy = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '80', sort: 'name' });
|
||||
if (this.storeQ) params.set('q', this.storeQ);
|
||||
if (this.storeChain) params.set('chain', this.storeChain);
|
||||
if (this.storeProvince) params.set('province', this.storeProvince);
|
||||
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
|
||||
this.storeResults = r.items || [];
|
||||
if (this.storeResults.length && !this.selectedStore) {
|
||||
await this.selectStore(this.storeResults[0].id);
|
||||
}
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
|
||||
async selectStore(id) {
|
||||
try {
|
||||
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
|
||||
this.selectedStore = r;
|
||||
this.storeForm = {
|
||||
address: r.address || '', postcode: r.postcode || '', city: r.city || '',
|
||||
province: r.province || '', phone: r.phone || '', email: r.email || '',
|
||||
website: r.website || '', manager_name: r.manager_name || r.profile_manager || '',
|
||||
partnership_status: r.partnership_status || 'none',
|
||||
halal_certified: !!r.halal_certified, has_halal_section: !!r.has_halal_section,
|
||||
halal_certifier: r.halal_certifier || '',
|
||||
};
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async saveStore() {
|
||||
if (!this.selectedStore) return;
|
||||
this.storeBusy = true;
|
||||
try {
|
||||
await fetch('/api/retail/supermarkets/' + this.selectedStore.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.storeForm),
|
||||
}).then(r => { if (!r.ok) throw new Error('Opslaan mislukt'); return r.json(); });
|
||||
Cockpit.toast('Filiaaldata opgeslagen', 'success');
|
||||
await this.selectStore(this.selectedStore.id);
|
||||
await this.searchStores();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
|
||||
async linkStore() {
|
||||
if (!this.selectedStore || !this.linkForm.client_id) return;
|
||||
try {
|
||||
await fetch('/api/retail/supermarkets/' + this.selectedStore.id + '/link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: parseInt(this.linkForm.client_id, 10),
|
||||
relationship_type: this.linkForm.relationship_type,
|
||||
partnership_status: this.storeForm.partnership_status !== 'none' ? this.storeForm.partnership_status : 'prospect',
|
||||
notes: this.linkForm.notes || null,
|
||||
}),
|
||||
}).then(r => { if (!r.ok) throw new Error('Koppelen mislukt'); return r.json(); });
|
||||
Cockpit.toast('Filiaal gekoppeld aan klant', 'success');
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
openStoreCreate() {
|
||||
this.newStore = { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' };
|
||||
this.storeCreateModal = true;
|
||||
},
|
||||
|
||||
async createStore() {
|
||||
try {
|
||||
const r = await fetch('/api/retail/supermarkets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.newStore),
|
||||
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
|
||||
Cockpit.toast('Filiaal toegevoegd', 'success');
|
||||
this.storeCreateModal = false;
|
||||
await this.searchStores();
|
||||
if (r.item?.id) await this.selectStore(r.item.id);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
async setStage(id, stage, name, email, notes) {
|
||||
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
|
||||
Cockpit.toast('Stage updated', 'success'); location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=8" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo">
|
||||
|
||||
<div class="herman-hero">
|
||||
<h1>Herman · Command Center <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">CEO overzicht · klanten · briefing · retail · alles op een rij</p>
|
||||
<p class="hero-sub">CEO overzicht · trending retail · briefing · platform links</p>
|
||||
<div class="hm-hero-links beurs-mini-row" style="margin-top:0.85rem">
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="beurs-mini-chip">📦 Gitea</a>
|
||||
<a href="/ops" class="beurs-mini-chip">🖥️ IT Ops</a>
|
||||
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
|
||||
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
|
||||
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
|
||||
<a href="/agents?tab=mesh" class="beurs-mini-chip">🕸️ Agent mesh</a>
|
||||
<a href="/settings?tab=social" class="beurs-mini-chip">⚙️ Social API's</a>
|
||||
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
|
||||
<a href="http://10.4.7.18:8700/docs" target="_blank" rel="noopener" class="beurs-mini-chip">📡 Tools API</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
|
||||
@@ -24,6 +35,7 @@
|
||||
<div class="briefing-meta" id="briefing-meta"></div>
|
||||
</div>
|
||||
<div class="herman-briefing-actions">
|
||||
<button type="button" class="btn btn-sm" @click="editLayout = !editLayout" x-text="editLayout ? 'Klaar ordenen' : '⋮⋮ Ordenen'"></button>
|
||||
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
|
||||
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
|
||||
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
|
||||
@@ -32,29 +44,38 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
|
||||
<div id="briefing-dashboard" class="hm-dash-layout">
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="kpis">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · CEO KPI's</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's</h3>
|
||||
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="executive">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Executive</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database · geen RSS — zie Marketing Hub</small></div>
|
||||
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
|
||||
<div id="briefing-executive-summary"></div>
|
||||
</div>
|
||||
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="briefing">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Briefing</div>
|
||||
<h3 class="hm-section-title">Herman briefing</h3>
|
||||
<div class="briefing-summary-grid">
|
||||
<div class="briefing-card summary">
|
||||
<h4>Samenvatting</h4>
|
||||
<p id="briefing-summary-text" class="briefing-typewriter"></p>
|
||||
</div>
|
||||
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
|
||||
<h4>📋 Herman activiteitenlog</h4>
|
||||
<ul id="briefing-activity-log" class="hm-exec-list" style="margin:0;padding-left:1.1rem;font-size:0.82rem"></ul>
|
||||
</div>
|
||||
<div class="briefing-horizon-col">
|
||||
<div class="horizon-card short">
|
||||
<h4>⚡ Korte termijn</h4>
|
||||
@@ -68,12 +89,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section">
|
||||
<section class="hm-dash-section hm-widget-draggable" data-widget="retail">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Retail</div>
|
||||
<h3 class="hm-section-title">Retail operatie</h3>
|
||||
<div class="hm-dash-two-col">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Top retail kansen</h4><small><a href="/retail">Retail 360 →</a></small></div>
|
||||
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub →</a></small></div>
|
||||
<div id="briefing-retail"></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
|
||||
@@ -86,72 +109,153 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full">
|
||||
<h3 class="hm-section-title">Data & analytics</h3>
|
||||
<section class="hm-dash-section hm-dash-full hm-dash-analytics hm-widget-draggable" data-widget="analytics">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Analytics</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
|
||||
<div class="hm-neo-charts">
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-panel hm-analytics-panel">
|
||||
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>NAS sentiment</h4><small>documenten</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-sentiment"></canvas></div>
|
||||
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
|
||||
<div id="viz-sentiment-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-panel hm-analytics-panel">
|
||||
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
|
||||
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Agent activiteit</h4><small>laatste events</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="chart-agents"></canvas></div>
|
||||
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Agent activiteit</h4><small id="viz-label-agents">neo-bars</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents"></canvas></div>
|
||||
<div id="viz-agents-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hm-dash-section hm-dash-full hm-widget-draggable" data-widget="approvals" id="widget-approvals">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Goedkeuringen</div>
|
||||
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Agent goedkeuringen <span class="live-badge" id="approvals-pending-badge" style="display:none">0 open</span></h3>
|
||||
<div class="panel" style="background:transparent;border:none;padding:0">
|
||||
<p class="page-tip">SysOps vraagt dagelijks toestemming voor <strong>update-scan</strong> en <strong>Gitea backup</strong>. Keur goed → actie wordt automatisch uitgevoerd.</p>
|
||||
<div id="dash-approvals-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hub-grid hm-dash-feed hm-widget-draggable" data-widget="feed">
|
||||
<div class="hm-widget-handle" x-show="editLayout">Sleep widget · Feed</div>
|
||||
<section class="panel">
|
||||
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
|
||||
<div id="agent-feed-live">
|
||||
{% for ev in agent_feed[:12] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
|
||||
{{ ev.title or ev.event_type }}
|
||||
</div>
|
||||
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
|
||||
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hub-grid hm-dash-feed">
|
||||
<section class="panel">
|
||||
<h2>Agent feed <span class="live-badge" id="agent-feed-badge">Live</span></h2>
|
||||
<div id="agent-feed-live">
|
||||
{% for ev in agent_feed[:12] %}
|
||||
<div class="hub-feed-item">
|
||||
<span>{{ ev.created_at[11:16] if ev.created_at else '' }}</span>
|
||||
<span class="badge badge-agent-{{ ev.agent_name|lower }}">{{ ev.agent_name }}</span>
|
||||
{{ ev.title or ev.event_type }}
|
||||
</div>
|
||||
{% else %}<p class="empty-state">Nog geen events.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Wacht op OK</h2>
|
||||
{% for item in approvals[:8] %}
|
||||
<div class="approval-item"><span class="badge">{{ item.agent_name }}</span> {{ item.title or item.event_type }}</div>
|
||||
{% else %}<p class="empty-state">Geen goedkeuringen 🎉</p>{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/briefing-charts.js?v=8"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=1"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=11"></script>
|
||||
<script>
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
function dashboardBriefing() {
|
||||
return {
|
||||
loading: false, loadingMsg: 'Herman werkt…',
|
||||
loading: false, loadingMsg: 'Herman werkt…', editLayout: false,
|
||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||
_pollStop: null, _wsStop: null,
|
||||
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
||||
async init() {
|
||||
window._dashboardVizMode = this._vizMode;
|
||||
const prefs = await DashboardLayout.loadPrefs();
|
||||
this._vizMode = prefs.global_viz_mode || 'neo-bars';
|
||||
window._dashboardVizMode = this._vizMode;
|
||||
DashboardLayout.applyOrder(document.getElementById('briefing-dashboard'), prefs.dashboard_layout || []);
|
||||
DashboardLayout.initDrag(document.getElementById('briefing-dashboard'), (order) => DashboardLayout.saveLayout(order));
|
||||
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
|
||||
this._vizMode = mode;
|
||||
window._dashboardVizMode = mode;
|
||||
await DashboardLayout.saveViz(mode);
|
||||
document.getElementById('viz-label-sentiment').textContent = mode;
|
||||
document.getElementById('viz-label-agents').textContent = mode;
|
||||
if (this._stats) BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, mode);
|
||||
});
|
||||
await this.refreshLive(false);
|
||||
await this.loadApprovals();
|
||||
await this.loadProjectsMini();
|
||||
if (this.content) BriefingCharts.renderText(document.getElementById('briefing-dashboard'), this.content, this.createdAt);
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refreshLive(false), 30000);
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); this.loadApprovals(); }, 30000);
|
||||
this._wsStop = CockpitLive.connectFeed((data) => {
|
||||
if (data.events) CockpitLive.renderAgentFeed(document.getElementById('agent-feed-live'), data.events);
|
||||
CockpitLive.updateLiveBadge(document.getElementById('agent-feed-badge'), new Date().toLocaleTimeString('nl-NL', {hour:'2-digit',minute:'2-digit', timeZone:'Europe/Amsterdam'}));
|
||||
});
|
||||
},
|
||||
async loadApprovals() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
|
||||
const el = document.getElementById('dash-approvals-list');
|
||||
const badge = document.getElementById('approvals-pending-badge');
|
||||
if (!el) return;
|
||||
const items = r.items || [];
|
||||
if (badge) {
|
||||
badge.style.display = items.length ? 'inline' : 'none';
|
||||
badge.textContent = items.length + ' open';
|
||||
}
|
||||
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
|
||||
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
|
||||
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
|
||||
items.map(i => '<tr class="'+(i.action_type==='maintenance_scan'?'approval-row-scan':'')+'"><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
|
||||
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓ Goedkeuren</button>' +
|
||||
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
|
||||
'</tbody></table><p class="page-tip" style="margin-top:0.5rem"><a href="/agents?tab=approvals">Volledige queue →</a></p>';
|
||||
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
|
||||
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
|
||||
}));
|
||||
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
|
||||
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
|
||||
}));
|
||||
} catch (e) {}
|
||||
},
|
||||
async loadProjectsMini() {
|
||||
try {
|
||||
const r = await fetch('/api/projects?limit=5').then(x => x.json());
|
||||
const el = document.getElementById('dash-projects-mini');
|
||||
if (!el) return;
|
||||
const items = r.items || [];
|
||||
el.innerHTML = items.length ? items.map(p =>
|
||||
'<a href="/projects?id='+p.id+'" class="beurs-mini-chip"><strong>'+p.name+'</strong> · '+(p.asset_count||0)+' assets</a>'
|
||||
).join('') : '<p class="muted">Nog geen projecten — <a href="/projects">maak aan</a></p>';
|
||||
} catch (e) {}
|
||||
},
|
||||
renderActivityLog(entries) {
|
||||
const el = document.getElementById('briefing-activity-log');
|
||||
if (!el) return;
|
||||
el.innerHTML = (entries || []).length
|
||||
? entries.slice(0, 12).map(e => '<li>' + e + '</li>').join('')
|
||||
: '<li class="muted">Nog geen agent-acties vandaag — Herman documenteert automatisch.</li>';
|
||||
},
|
||||
renderClientsMini(stats) {
|
||||
const el = document.getElementById('briefing-clients-mini');
|
||||
if (!el) return;
|
||||
@@ -166,8 +270,10 @@ function dashboardBriefing() {
|
||||
try {
|
||||
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
||||
if (r.stats) {
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats);
|
||||
this._stats = r.stats;
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), r.stats, this._vizMode);
|
||||
this.renderClientsMini(r.stats);
|
||||
this.renderActivityLog(r.stats.activity_log || []);
|
||||
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
||||
@@ -182,7 +288,7 @@ function dashboardBriefing() {
|
||||
const r = await Cockpit.api('/api/herman/briefing', { method: 'POST' });
|
||||
this.content = r.content || '';
|
||||
this.createdAt = r.generated_at || new Date().toISOString();
|
||||
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt);
|
||||
BriefingCharts.render(document.getElementById('briefing-dashboard'), r.stats, r.content, this.createdAt, this._vizMode);
|
||||
this.renderClientsMini(r.stats || {});
|
||||
Cockpit.toast('Dagrapport klaar!', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=9" />
|
||||
<style>
|
||||
.doc-neo-shell { --hm-cyan: #00e5ff; --hm-border: rgba(0, 229, 255, 0.12); --hm-panel: #0d1219; }
|
||||
.doc-neo-shell .page-header h1 { font-size: 1.55rem; }
|
||||
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
|
||||
.doc-chart-panel { height: 140px; }
|
||||
.doc-chart-panel canvas { max-height: 110px !important; }
|
||||
.doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
|
||||
@media (max-width: 900px) { .doc-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
|
||||
.doc-neo-kpi {
|
||||
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
|
||||
border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.1rem 0.65rem;
|
||||
}
|
||||
.doc-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: #94a3b8; }
|
||||
.doc-neo-kpi-value { font-size: 1.65rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0; }
|
||||
.doc-chart-grid { display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:1rem; }
|
||||
@media (max-width: 800px) { .doc-chart-grid { grid-template-columns: 1fr; } }
|
||||
.doc-chart-panel .hm-chart-wrap { height: 130px; }
|
||||
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
|
||||
.photo-card { border:1px solid rgba(148,163,184,.15); border-radius:10px; overflow:hidden; background:rgba(0,0,0,.2); }
|
||||
.photo-card { border:1px solid rgba(0,229,255,0.12); border-radius:12px; overflow:hidden; background:rgba(13,18,25,0.95); }
|
||||
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
|
||||
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
|
||||
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
|
||||
@@ -15,17 +27,33 @@
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="documentsDash()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>Documents & Foto's — interactief dashboard</h1>
|
||||
<p class="subtitle page-tip">Filter NAS documenten, bekijk compacte charts, analyseer foto's uit share/Telegram/browser met OCR + detectie.</p>
|
||||
<div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
|
||||
<header class="page-header herman-hero" style="margin-bottom:1rem">
|
||||
<h1>Documents & Foto's <span class="live-badge">Neo</span></h1>
|
||||
<p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
|
||||
</header>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card clickable-kpi" @click="filterSentiment=''"><div class="kpi-label">Documenten</div><div class="kpi-value" x-text="filteredDocs.length"></div></div>
|
||||
<div class="kpi-card purple clickable-kpi"><div class="kpi-label">Totaal woorden</div><div class="kpi-value">{{ summary.total_words }}</div></div>
|
||||
<div class="kpi-card green clickable-kpi"><div class="kpi-label">Foto's in DB</div><div class="kpi-value" x-text="photos.length"></div></div>
|
||||
<div class="kpi-card orange clickable-kpi"><div class="kpi-label">Gem. sentiment</div><div class="kpi-value">{{ summary.avg_sentiment }}</div></div>
|
||||
<div class="doc-neo-kpi-row">
|
||||
<div class="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
|
||||
<div class="doc-neo-kpi-label">Documenten</div>
|
||||
<div class="doc-neo-kpi-value" x-text="filteredDocs.length"></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="doc-neo-kpi clickable-kpi">
|
||||
<div class="doc-neo-kpi-label">Totaal woorden</div>
|
||||
<div class="doc-neo-kpi-value">{{ summary.total_words }}</div>
|
||||
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="doc-neo-kpi clickable-kpi">
|
||||
<div class="doc-neo-kpi-label">Foto's in DB</div>
|
||||
<div class="doc-neo-kpi-value" x-text="photos.length"></div>
|
||||
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="doc-neo-kpi clickable-kpi">
|
||||
<div class="doc-neo-kpi-label">Gem. sentiment</div>
|
||||
<div class="doc-neo-kpi-value">{{ summary.avg_sentiment }}</div>
|
||||
<div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-dash-filters panel">
|
||||
@@ -46,14 +74,18 @@
|
||||
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-top:1rem">
|
||||
<section class="panel doc-chart-panel">
|
||||
<h2>Top woorden <small class="muted">klik = filter</small></h2>
|
||||
<canvas id="wordsChart" height="110"></canvas>
|
||||
<div class="doc-chart-grid">
|
||||
<section class="hm-chart-panel doc-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="wordsChart"></canvas></div>
|
||||
<div id="viz-words-alt" class="viz-alt"></div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</section>
|
||||
<section class="panel doc-chart-panel">
|
||||
<h2>Sentiment <small class="muted">klik = filter</small></h2>
|
||||
<canvas id="sentimentChart" height="110"></canvas>
|
||||
<section class="hm-chart-panel doc-chart-panel">
|
||||
<div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="sentimentChart"></canvas></div>
|
||||
<div id="viz-sentiment-alt" class="viz-alt"></div>
|
||||
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -132,8 +164,18 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
|
||||
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
|
||||
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
|
||||
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem" x-show="activePhoto">
|
||||
<select class="form-input" x-model="linkProjectId" style="max-width:200px">
|
||||
<option value="">Koppel aan project…</option>
|
||||
<template x-for="p in cockpitProjects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" @click="savePhotoToProject()">Opslaan in project</button>
|
||||
<a href="/projects" class="btn btn-sm">Alle projecten →</a>
|
||||
</div>
|
||||
<div class="detection-wrap">
|
||||
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
|
||||
<canvas id="detectionCanvas"></canvas>
|
||||
@@ -172,14 +214,28 @@ function documentsDash() {
|
||||
activeOcr: '',
|
||||
activeItems: [],
|
||||
detections: [],
|
||||
linkProjectId: '',
|
||||
cockpitProjects: [],
|
||||
liveRefresh: false,
|
||||
liveTimer: null,
|
||||
|
||||
init() {
|
||||
this.applyFilters();
|
||||
this.renderCharts();
|
||||
this.renderCharts(window.__vizMode || 'neo-bars');
|
||||
this.loadPhotos();
|
||||
this.refreshShare(false);
|
||||
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
|
||||
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
|
||||
},
|
||||
async savePhotoToProject() {
|
||||
if (!this.activePhoto || !this.linkProjectId) return Cockpit.toast('Kies een project', 'error');
|
||||
try {
|
||||
await Cockpit.api('/api/projects/photos/' + this.activePhoto + '/link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project_id: parseInt(this.linkProjectId, 10) }),
|
||||
});
|
||||
Cockpit.toast('Foto gekoppeld aan project', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
mergeShareItems(items) {
|
||||
@@ -235,16 +291,44 @@ function documentsDash() {
|
||||
});
|
||||
},
|
||||
|
||||
renderCharts() {
|
||||
if (wordsChartInst) wordsChartInst.destroy();
|
||||
if (sentimentChartInst) sentimentChartInst.destroy();
|
||||
renderCharts(mode) {
|
||||
mode = mode || window.__vizMode || 'neo-bars';
|
||||
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
|
||||
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
|
||||
const dash = this;
|
||||
const wordLabels = topWords.slice(0, 10).map(w => w.lemma);
|
||||
const wordValues = topWords.slice(0, 10).map(w => w.total_count);
|
||||
const sentLabels = ['Positief', 'Neutraal', 'Negatief'];
|
||||
const sentValues = [summary.positive, summary.neutral, summary.negative];
|
||||
if (window.VizEngine && (mode === 'neo-equalizer' || mode === 'neo-cards' || mode === 'neo-table')) {
|
||||
VizEngine.renderWidget(mode, {
|
||||
canvas: document.getElementById('wordsChart'),
|
||||
altContainer: document.getElementById('viz-words-alt'),
|
||||
key: 'doc-words',
|
||||
labels: wordLabels, values: wordValues,
|
||||
rows: wordLabels.map((l, i) => ({ label: l, value: wordValues[i], raw: wordValues[i] })),
|
||||
eqClass: 'hm-eq-cyan',
|
||||
});
|
||||
VizEngine.renderWidget(mode, {
|
||||
canvas: document.getElementById('sentimentChart'),
|
||||
altContainer: document.getElementById('viz-sentiment-alt'),
|
||||
key: 'doc-sentiment',
|
||||
labels: sentLabels, values: sentValues,
|
||||
rows: sentLabels.map((l, i) => ({ label: l, value: sentValues[i], raw: sentValues[i] })),
|
||||
eqClass: 'hm-eq-purple',
|
||||
});
|
||||
return;
|
||||
}
|
||||
document.getElementById('viz-words-alt').innerHTML = '';
|
||||
document.getElementById('viz-sentiment-alt').innerHTML = '';
|
||||
document.getElementById('wordsChart').style.display = '';
|
||||
document.getElementById('sentimentChart').style.display = '';
|
||||
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
|
||||
type: 'bar',
|
||||
type: mode === 'neo-rings' ? 'polarArea' : 'bar',
|
||||
data: {
|
||||
labels: topWords.slice(0, 10).map(w => w.lemma),
|
||||
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
|
||||
backgroundColor: 'rgba(232,168,56,.75)', borderWidth: 0 }]
|
||||
backgroundColor: 'rgba(0,229,255,0.75)', borderWidth: 0, borderRadius: 6 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
@@ -255,11 +339,11 @@ function documentsDash() {
|
||||
}
|
||||
});
|
||||
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
|
||||
type: 'doughnut',
|
||||
type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
|
||||
data: {
|
||||
labels: ['Positief', 'Neutraal', 'Negatief'],
|
||||
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
|
||||
backgroundColor: ['#3ecf8e', '#6b7280', '#ef4444'] }]
|
||||
backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
<div x-data="hermanChat()" class="chat-layout">
|
||||
<header class="page-header">
|
||||
<h1>Herman</h1>
|
||||
<p class="subtitle page-tip">Co-CEO orchestrator — routes your message to specialist agents.</p>
|
||||
<p class="subtitle page-tip">Co-CEO orchestrator — delegeert naar specialist agents (packaging, design, …).</p>
|
||||
</header>
|
||||
|
||||
<div class="card card-compact" style="margin-bottom:1rem;">
|
||||
<h2 style="font-size:1rem;margin:0 0 .5rem;">Verpakking via Packaging agent</h2>
|
||||
<p class="page-tip" style="margin:0 0 .5rem;">Start met <code>maak verpakking:</code> — de agent genereert het design, slaat op op NAS en rapporteert aan Herman.</p>
|
||||
<p class="page-tip" style="margin:0;font-size:0.78rem">Voorbeeld: <em>maak verpakking: pouch 100×140 voor Halal Kip Curry project #5 halal-badge</em></p>
|
||||
</div>
|
||||
|
||||
<div class="card card-compact" style="margin-bottom:1rem;">
|
||||
<h2 style="font-size:1rem;margin:0 0 .5rem;">Foto genereren (ComfyUI)</h2>
|
||||
<p class="page-tip" style="margin:0 0 .75rem;">Beschrijf wat je wilt zien. Op CPU duurt dit 1–3 minuten.</p>
|
||||
@@ -30,7 +36,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form class="chat-input" @submit.prevent="send">
|
||||
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak foto: product op witte achtergrond)" autocomplete="off" />
|
||||
<input class="form-input" x-model="message" placeholder="Message Herman… (bijv. maak verpakking: doos 120×80×40 voor Chai N Masala project #3)" autocomplete="off" />
|
||||
<button class="btn btn-primary" type="submit" :disabled="loading">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -62,6 +68,11 @@ function hermanChat() {
|
||||
html += '<img src="' + r.image_url + '" alt="Gegenereerd" style="max-width:100%;margin-top:.5rem;border-radius:8px;" />';
|
||||
this.lastImage = r.image_url;
|
||||
}
|
||||
if (r.packaging_studio_url) {
|
||||
html += '<p style="margin-top:.5rem"><a class="btn btn-sm" href="' + esc(r.packaging_studio_url) + '">📦 Open Packaging Studio</a>';
|
||||
if (r.packaging_pdf_url) html += ' <a class="btn btn-sm btn-ghost" href="' + esc(r.packaging_pdf_url) + '" target="_blank">PDF</a>';
|
||||
html += '</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
log.innerHTML += html;
|
||||
this.message = '';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="marketingHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
@@ -9,9 +13,29 @@
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
|
||||
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
|
||||
<a class="btn btn-sm" href="/projects">Projecten →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
|
||||
<div class="hm-dash-two-col" style="margin-bottom:1rem">
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Marketing data</h4><small>RSS · trends · kansen</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing"></canvas></div>
|
||||
<div id="viz-marketing-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Publish jobs</h4><small>status verdeling</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing-publish"></canvas></div>
|
||||
<div id="viz-marketing-publish-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="live-feed-bar" x-show="dashboard.rss?.length">
|
||||
<div class="ticker-track">
|
||||
<template x-for="r in dashboard.rss" :key="r.id">
|
||||
@@ -191,6 +215,12 @@
|
||||
<textarea class="form-input" rows="5" x-model="publishForm.text" placeholder="Post tekst — UTF-8 emoji ondersteund 🥘✨" style="width:100%;margin-bottom:0.75rem"></textarea>
|
||||
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
|
||||
<select class="form-input" x-model="publishForm.project_id" style="max-width:220px">
|
||||
<option value="">Opslaan in agent-project (auto)</option>
|
||||
<template x-for="p in cockpitProjects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<label class="btn btn-secondary btn-sm">
|
||||
📷 Foto uploaden
|
||||
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
|
||||
@@ -359,6 +389,8 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard-layout.js?v=2"></script>
|
||||
<script src="/static/js/page-viz.js?v=1"></script>
|
||||
<script>
|
||||
function marketingHub() {
|
||||
return {
|
||||
@@ -369,8 +401,9 @@ function marketingHub() {
|
||||
promos: [], bookmarks: [], bookmarkIds: {},
|
||||
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
|
||||
reclameStats: { chains: 0 },
|
||||
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [] },
|
||||
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
|
||||
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
|
||||
cockpitProjects: [],
|
||||
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨🍳','💚','📁','🎯'],
|
||||
promoForm: { chain: '', title: '', folder_path: '', description: '' },
|
||||
_pollStop: null,
|
||||
@@ -385,11 +418,16 @@ function marketingHub() {
|
||||
async init() {
|
||||
await this.loadDashboard();
|
||||
await this.loadBookmarks();
|
||||
await this.loadPublishHistory();
|
||||
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
|
||||
if (this.tab === 'regelgeving') await this.loadRegulations();
|
||||
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
|
||||
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
|
||||
this.$nextTick(() => this.updateTabIndicator());
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('marketing', (mode) => this.renderViz(mode));
|
||||
}
|
||||
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
|
||||
const el=document.getElementById('reco-list');
|
||||
if(!el) return;
|
||||
@@ -510,6 +548,7 @@ function marketingHub() {
|
||||
try {
|
||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||
await this.loadDashboard();
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
||||
} finally { this.busy = false; }
|
||||
},
|
||||
@@ -540,8 +579,42 @@ function marketingHub() {
|
||||
try {
|
||||
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
|
||||
this.publishHistory = r.items || [];
|
||||
if (window.__marketingRenderViz) window.__marketingRenderViz(window.__vizMode || 'neo-bars');
|
||||
} catch (e) { this.publishHistory = []; }
|
||||
},
|
||||
renderViz(mode) {
|
||||
window.__vizMode = mode;
|
||||
window.__marketingRenderViz = (m) => this.renderViz(m);
|
||||
if (!window.VizEngine) return;
|
||||
const d = this.dashboard || {};
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-marketing'),
|
||||
document.getElementById('viz-marketing-alt'),
|
||||
'marketing-main', 'Marketing KPIs',
|
||||
[
|
||||
{ label: 'RSS', value: (d.rss || []).length, raw: (d.rss || []).length },
|
||||
{ label: 'Trends', value: (d.trends || []).length, raw: (d.trends || []).length },
|
||||
{ label: 'Kansen', value: (d.top_opportunities || []).length, raw: (d.top_opportunities || []).length },
|
||||
{ label: 'Promo', value: this.promos.length, raw: this.promos.length },
|
||||
],
|
||||
'hm-eq-purple', VizEngine.MODES ? null : null
|
||||
);
|
||||
const statusMap = {};
|
||||
(this.publishHistory || []).forEach(j => {
|
||||
const s = j.status || 'unknown';
|
||||
statusMap[s] = (statusMap[s] || 0) + 1;
|
||||
});
|
||||
const labels = Object.keys(statusMap);
|
||||
const values = labels.map(k => statusMap[k]);
|
||||
if (!labels.length) { labels.push('geen'); values.push(0); }
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-marketing-publish'),
|
||||
document.getElementById('viz-marketing-publish-alt'),
|
||||
'marketing-publish', 'Publish status',
|
||||
labels.map((l, i) => ({ label: l, value: values[i], raw: values[i] })),
|
||||
'hm-eq-cyan'
|
||||
);
|
||||
},
|
||||
publishResultSummary(j) {
|
||||
const res = j.result || {};
|
||||
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } }
|
||||
@@ -573,6 +646,7 @@ function marketingHub() {
|
||||
image_url: this.publishForm.image_url || null,
|
||||
media_ids: this.publishForm.media_ids || [],
|
||||
channels: this.publishForm.channels,
|
||||
project_id: this.publishForm.project_id ? parseInt(this.publishForm.project_id, 10) : null,
|
||||
};
|
||||
const r = await fetch('/api/marketing/publish', {
|
||||
method: 'POST',
|
||||
|
||||
+190
-89
@@ -1,131 +1,232 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
|
||||
<header class="ops-header">
|
||||
<div class="ops-shell hm-neo" x-data="opsPage()" x-init="init()">
|
||||
<header class="ops-header herman-hero">
|
||||
<div>
|
||||
<h1>IT Ops Infrastructure</h1>
|
||||
<p class="ops-subtitle">Realtime Proxmox + service health topology</p>
|
||||
<h1>IT Ops · Dell R340 <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">PowerEdge R340 → Proxmox VE → VM 106 → online services · SysOps agent</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">↻ Topology</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</button>
|
||||
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="snapshot()" :disabled="loading">Snapshot</button>
|
||||
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-purple">Goedkeuringen →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ops-kpis">
|
||||
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></div>
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div class="hm-dash-two-col" style="margin-bottom:1rem">
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Maintenance severity</h4><small>open items</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-maint"></canvas></div>
|
||||
<div id="viz-ops-maint-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Backup status</h4><small>Gitea commits</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-backup"></canvas></div>
|
||||
<div id="viz-ops-backup-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="ops-kpis hm-neo-kpi-row" style="grid-template-columns:repeat(5,1fr)">
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Health</div><div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Online nodes</div><div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? 0"></div><div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Services</div><div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div><div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Open maintenance</div><div class="hm-neo-kpi-value" x-text="maintenance.length"></div><div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Laatste backup</div><div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="lastBackupLabel()"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
</section>
|
||||
|
||||
<div class="ops-panels-grid">
|
||||
<section class="panel ops-side-panel">
|
||||
<h2>Maintenance · SysOps</h2>
|
||||
<p class="page-tip">Dagelijks scan — updates, schijf, Docker health</p>
|
||||
<template x-if="!maintenance.length">
|
||||
<p class="empty-state">Geen open items 🎉</p>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="n in maintenance" :key="n.id">
|
||||
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
|
||||
<strong x-text="n.title"></strong>
|
||||
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="n.body" x-show="n.body"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel ops-side-panel">
|
||||
<h2>Config backups · Gitea</h2>
|
||||
<p class="page-tip">SysOps vraagt dagelijks goedkeuring — na ✓ push naar Gitea</p>
|
||||
<template x-if="pendingBackup">
|
||||
<div class="ops-maint-item sev-warning">
|
||||
<strong>⏳ Wacht op goedkeuring</strong>
|
||||
<p x-text="pendingBackup.title"></p>
|
||||
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-green">Keur goed →</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="b in backups.slice(0,5)" :key="b.id">
|
||||
<div class="ops-maint-item" :class="b.status==='success'?'sev-info':'sev-warning'">
|
||||
<strong x-text="b.status + (b.commit_ref ? ' · '+b.commit_ref : '')"></strong>
|
||||
<small x-text="(b.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="b.message"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="btn btn-sm" style="margin-top:0.5rem">Gitea repo →</a>
|
||||
</section>
|
||||
|
||||
<section class="panel ops-side-panel" style="grid-column:1/-1">
|
||||
<h2>SysOps verslag · activiteitenlog</h2>
|
||||
<p class="page-tip">Elke deploy, Gitea-push en maintenance-scan — Herman leest dit in de dagelijkse briefing.</p>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="a in sysopsActivity" :key="a.id">
|
||||
<div class="ops-maint-item" :class="a.status==='success'?'sev-info':'sev-warning'">
|
||||
<strong x-text="a.title"></strong>
|
||||
<small x-text="(a.action_type||'')+' · '+(a.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="a.body" x-show="a.body"></p>
|
||||
<small x-show="a.commit_ref" style="color:#38bdf8" x-text="'commit '+a.commit_ref"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!sysopsActivity.length" class="empty-state">Nog geen SysOps-activiteit gelogd.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel topology-card">
|
||||
<svg viewBox="0 0 1200 600" class="topology-canvas">
|
||||
<g class="topology-lines">
|
||||
<template x-for="edge in edges" :key="edge.id">
|
||||
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<g>
|
||||
<template x-for="edge in edges" :key="'pulse-'+edge.id">
|
||||
<line class="pulse-line" :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<template x-for="node in nodes" :key="node.id">
|
||||
<g class="ops-node" :class="node.css" :transform="'translate('+node.x+','+node.y+')'">
|
||||
<circle r="36"></circle>
|
||||
<text text-anchor="middle" y="-2" x-text="node.label"></text>
|
||||
<text class="sub" text-anchor="middle" y="16" x-text="node.status"></text>
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
<div class="ops-legend">
|
||||
<span><i class="dot dot-online"></i> Online / bereikbaar</span>
|
||||
<span><i class="dot dot-degraded"></i> Onbekend</span>
|
||||
<span><i class="dot dot-offline"></i> Offline</span>
|
||||
<span>Klik service-node → opent URL</span>
|
||||
</div>
|
||||
<svg id="ops-topology-canvas" viewBox="0 0 1200 620" class="topology-canvas"></svg>
|
||||
</section>
|
||||
|
||||
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
|
||||
<p class="ops-meta">Last update: <span id="ops-meta-time">—</span></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard-layout.js?v=2"></script>
|
||||
<script src="/static/js/page-viz.js?v=1"></script>
|
||||
<script src="/static/js/ops-topology.js?v=3"></script>
|
||||
<script>
|
||||
function opsTopology() {
|
||||
function opsPage() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: { counts: {} },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastUpdate: null,
|
||||
meta: {},
|
||||
maintenance: [],
|
||||
backups: [],
|
||||
sysopsActivity: [],
|
||||
pendingBackup: null,
|
||||
timer: null,
|
||||
positions: {
|
||||
'proxmox-host': { x: 600, y: 90 },
|
||||
'vm105-hermes': { x: 340, y: 230 },
|
||||
'vm106-command': { x: 860, y: 230 },
|
||||
'svc-gitea': { x: 190, y: 390 },
|
||||
'svc-ollama': { x: 480, y: 390 },
|
||||
'svc-cockpit': { x: 700, y: 390 },
|
||||
'svc-tools-api': { x: 860, y: 390 },
|
||||
'svc-email-agent': { x: 1020, y: 390 }
|
||||
renderViz(mode) {
|
||||
if (!window.VizEngine) return;
|
||||
const sevMap = {};
|
||||
(this.maintenance || []).forEach(n => {
|
||||
const s = n.severity || 'info';
|
||||
sevMap[s] = (sevMap[s] || 0) + 1;
|
||||
});
|
||||
let sevRows = Object.keys(sevMap).map(k => ({ label: k, value: sevMap[k], raw: sevMap[k] }));
|
||||
if (!sevRows.length) sevRows = [{ label: 'ok', value: 0, raw: 0 }];
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-ops-maint'),
|
||||
document.getElementById('viz-ops-maint-alt'),
|
||||
'ops-maint', 'Maintenance', sevRows, 'hm-eq-purple'
|
||||
);
|
||||
const stMap = {};
|
||||
(this.backups || []).forEach(b => {
|
||||
const s = b.status || 'unknown';
|
||||
stMap[s] = (stMap[s] || 0) + 1;
|
||||
});
|
||||
let stRows = Object.keys(stMap).map(k => ({ label: k, value: stMap[k], raw: stMap[k] }));
|
||||
if (!stRows.length) stRows = [{ label: 'geen', value: 0, raw: 0 }];
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-ops-backup'),
|
||||
document.getElementById('viz-ops-backup-alt'),
|
||||
'ops-backup', 'Backups', stRows, 'hm-eq-cyan'
|
||||
);
|
||||
},
|
||||
init() {
|
||||
this.loadStatus();
|
||||
this.loadTopology();
|
||||
this.timer = setInterval(() => this.loadTopology(), 30000);
|
||||
async init() {
|
||||
await this.loadAll();
|
||||
await this.draw();
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('ops', (mode) => this.renderViz(mode));
|
||||
}
|
||||
this.timer = setInterval(() => this.refresh(false), 30000);
|
||||
},
|
||||
lastBackupLabel() {
|
||||
const ok = this.backups.find(b => b.status === 'success');
|
||||
if (!ok) return '—';
|
||||
return (ok.commit_ref || ok.status) + ' · ' + (ok.created_at || '').substring(0, 10);
|
||||
},
|
||||
async loadAll() {
|
||||
await Promise.all([this.loadStatus(), this.loadMaintenance(), this.loadBackups(), this.loadSysopsActivity(), this.loadPendingApproval()]);
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
},
|
||||
async loadStatus() {
|
||||
try {
|
||||
const data = await fetch('/api/ops/status').then(r => r.json());
|
||||
this.summary = data || { counts: {} };
|
||||
this.lastUpdate = data.generated_at || new Date().toISOString();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
this.meta = (data.topology && data.topology.meta) || data.sources || {};
|
||||
} catch (e) { console.warn(e); }
|
||||
},
|
||||
async loadMaintenance() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/maintenance?limit=12').then(x => x.json());
|
||||
this.maintenance = (r.items || []).filter(n => !n.resolved);
|
||||
} catch (e) { this.maintenance = []; }
|
||||
},
|
||||
async loadBackups() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/backups?limit=8').then(x => x.json());
|
||||
this.backups = r.items || [];
|
||||
} catch (e) { this.backups = []; }
|
||||
},
|
||||
async loadSysopsActivity() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/sysops-activity?limit=20').then(x => x.json());
|
||||
this.sysopsActivity = r.items || [];
|
||||
} catch (e) { this.sysopsActivity = []; }
|
||||
},
|
||||
async loadPendingApproval() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending&limit=20').then(x => x.json());
|
||||
this.pendingBackup = (r.items || []).find(i => i.agent_key === 'sysops' && i.action_type === 'config_backup') || null;
|
||||
} catch (e) { this.pendingBackup = null; }
|
||||
},
|
||||
async draw() {
|
||||
if (window.OpsTopology) {
|
||||
await window.OpsTopology.render('ops-topology-canvas', document.getElementById('ops-meta-time'));
|
||||
}
|
||||
},
|
||||
async loadTopology() {
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await fetch('/api/ops/topology').then(r => r.json());
|
||||
this.toGraph(data);
|
||||
await this.loadStatus();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
await this.loadAll();
|
||||
await this.draw();
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast('IT Ops bijgewerkt', 'success');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
async refreshInfra() {
|
||||
async snapshot() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await fetch('/api/ops/refresh', { method: 'POST' });
|
||||
await this.loadTopology();
|
||||
await this.refresh(false);
|
||||
if (window.Cockpit) Cockpit.toast('Infra snapshot opgeslagen', 'success');
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot mislukt', 'error');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
toGraph(payload) {
|
||||
const outNodes = [];
|
||||
const outEdges = [];
|
||||
const flatten = (n, parentId = null) => {
|
||||
const pos = this.positions[n.id] || { x: 80 + outNodes.length * 120, y: 520 };
|
||||
const status = (n.status || 'unknown').toLowerCase();
|
||||
const kind = (n.type || 'service').toLowerCase();
|
||||
const css = `node-${kind} status-${status}`;
|
||||
outNodes.push({ id: n.id, label: n.label, status: n.status || 'unknown', x: pos.x, y: pos.y, css });
|
||||
if (parentId) {
|
||||
const p = this.positions[parentId];
|
||||
if (p) {
|
||||
outEdges.push({ id: `${parentId}-${n.id}`, x1: p.x, y1: p.y + 36, x2: pos.x, y2: pos.y - 36 });
|
||||
}
|
||||
}
|
||||
(n.children || []).forEach(child => flatten(child, n.id));
|
||||
};
|
||||
(payload.nodes || []).forEach(root => flatten(root, null));
|
||||
this.nodes = outNodes;
|
||||
this.edges = outEdges;
|
||||
this.lastUpdate = payload.generated_at || new Date().toISOString();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
+333
-105
@@ -1,92 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.pack-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
|
||||
@media (max-width: 1024px) { .pack-grid { grid-template-columns: 1fr; } }
|
||||
.pack-preview {
|
||||
min-height: 460px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top, rgba(0,229,255,.08), rgba(0,0,0,.35));
|
||||
border: 1px solid rgba(0,229,255,.2);
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.pack-preview svg { width: 100%; max-height: 72vh; }
|
||||
.pack-tools { display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.75rem; }
|
||||
.elements-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; }
|
||||
.project-list { max-height: 260px; overflow: auto; display:flex; flex-direction:column; gap:.5rem; }
|
||||
.project-item { border:1px solid rgba(148,163,184,.15); border-radius:10px; padding:.6rem; background:rgba(0,0,0,.25); }
|
||||
.project-item small { color: var(--text-secondary); display:block; margin-top:.25rem; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="packagingStudio()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<h1>Packaging Studio</h1>
|
||||
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p>
|
||||
<div class="pack-studio" x-data="packagingStudio()" x-init="init()">
|
||||
<header class="pack-hero">
|
||||
<div>
|
||||
<h1>📦 Packaging Studio</h1>
|
||||
<p>Ontwerp verpakkingen met Python SVG-engine · koppel aan project · export naar NAS · email & kopieer naar ander project.</p>
|
||||
</div>
|
||||
<div class="pack-toolbar">
|
||||
<button class="btn btn-sm" @click="preview()" :disabled="loading">👁 Preview</button>
|
||||
<button class="btn btn-primary btn-sm" @click="save()" :disabled="loading">
|
||||
<span x-text="packagingId ? '💾 Bijwerken' : '💾 Opslaan'"></span>
|
||||
</button>
|
||||
<button class="btn btn-sm" @click="duplicate()" x-show="packagingId" :disabled="loading">📋 Dupliceer</button>
|
||||
<button class="btn btn-sm" @click="showCopyModal=true" x-show="packagingId">📁 Kopieer naar project</button>
|
||||
<button class="btn btn-sm" @click="showEmailModal=true" x-show="packagingId">✉ Email</button>
|
||||
<a class="btn btn-sm btn-ghost" :href="downloadUrl('svg')" x-show="packagingId">SVG</a>
|
||||
<a class="btn btn-sm btn-ghost" :href="downloadUrl('png')" x-show="packagingId">PNG</a>
|
||||
<a class="btn btn-sm btn-ghost" :href="downloadUrl('pdf')" x-show="packagingId">PDF</a>
|
||||
<button class="btn btn-sm btn-ghost" @click="newDesign()">+ Nieuw</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pack-grid">
|
||||
<section class="panel">
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;">
|
||||
<label>Type
|
||||
<select class="form-input" x-model="form.type">
|
||||
<option value="folding_box">Folding box</option>
|
||||
<option value="wrap">Wrap</option>
|
||||
<option value="round_label">Round label</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Barcode value
|
||||
<input class="form-input" x-model="form.barcode_value" placeholder="8710000000012" />
|
||||
</label>
|
||||
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" /></label>
|
||||
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" /></label>
|
||||
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" /></label>
|
||||
<div class="pack-layout">
|
||||
<aside class="pack-panel">
|
||||
<div class="pack-tabs">
|
||||
<button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
|
||||
<button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
|
||||
<button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
|
||||
<button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
|
||||
<button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2>
|
||||
<div class="elements-grid">
|
||||
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label>
|
||||
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label>
|
||||
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label>
|
||||
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</label>
|
||||
<div x-show="sideTab==='shape'">
|
||||
<h3>Afmetingen & type</h3>
|
||||
<div class="pack-field-grid">
|
||||
<label class="full">Design naam
|
||||
<input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
|
||||
<label class="full">Type
|
||||
<select class="form-input" x-model="form.type">
|
||||
<template x-for="t in types" :key="t.id">
|
||||
<option :value="t.id" x-text="t.label"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" @input="debouncedPreview()" /></label>
|
||||
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" @input="debouncedPreview()" /></label>
|
||||
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" @input="debouncedPreview()" /></label>
|
||||
<label>Bleed (mm)<input type="number" class="form-input" x-model.number="form.bleed_mm" min="0" max="20" @input="debouncedPreview()" /></label>
|
||||
<label class="full">Barcode<input class="form-input" x-model="form.barcode_value" @input="debouncedPreview()" /></label>
|
||||
<label class="full">QR URL<input class="form-input" x-model="form.qr_value" placeholder="https://foodlinkk.nl/product/..." @input="debouncedPreview()" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pack-tools">
|
||||
<button class="btn btn-primary" @click="generate()" :disabled="loading">
|
||||
<span x-show="!loading">Genereer verpakking</span>
|
||||
<span x-show="loading">Genereren…</span>
|
||||
</button>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a>
|
||||
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a>
|
||||
<div x-show="sideTab==='brand'" x-cloak>
|
||||
<h3>Kleuren & stijl</h3>
|
||||
<div class="pack-field-grid">
|
||||
<template x-for="c in colorFields" :key="c.key">
|
||||
<label class="full" x-text="c.label">
|
||||
<div class="pack-color-row">
|
||||
<input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
|
||||
<input type="color" :value="form.brand[c.key]" @input="form.brand[c.key]=$event.target.value; debouncedPreview()" />
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="sideTab==='text'" x-cloak>
|
||||
<h3>Producttekst</h3>
|
||||
<div class="pack-field-grid">
|
||||
<label class="full">Productnaam<input class="form-input" x-model="form.text.product_name" @input="debouncedPreview()" /></label>
|
||||
<label class="full">Tagline<input class="form-input" x-model="form.text.tagline" @input="debouncedPreview()" /></label>
|
||||
<label>Netto gewicht<input class="form-input" x-model="form.text.subtitle" placeholder="400g" @input="debouncedPreview()" /></label>
|
||||
<label>Herkomst<input class="form-input" x-model="form.text.origin" @input="debouncedPreview()" /></label>
|
||||
<label class="full">Ingrediënten<textarea class="form-input" rows="3" x-model="form.text.ingredients" @input="debouncedPreview()"></textarea></label>
|
||||
<label class="full">THT / best before<input class="form-input" x-model="form.text.best_before" @input="debouncedPreview()" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="sideTab==='elements'" x-cloak>
|
||||
<h3>Print & productie-elementen</h3>
|
||||
<div class="pack-elements">
|
||||
<template x-for="el in elementList" :key="el.key">
|
||||
<label><input type="checkbox" x-model="form.elements[el.key]" @change="debouncedPreview()"> <span x-text="el.label"></span></label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="sideTab==='project'" x-cloak>
|
||||
<h3>Project & NAS</h3>
|
||||
<div class="pack-field-grid">
|
||||
<label class="full">Bestaand project
|
||||
<select class="form-input" x-model="save.project_id">
|
||||
<option value="">— Nieuw project —</option>
|
||||
<template x-for="p in cockpitProjects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<label class="full" x-show="!save.project_id">Nieuw projectnaam
|
||||
<input class="form-input" x-model="save.project_name" placeholder="Jumbo pouch Q3" /></label>
|
||||
<label class="full" x-show="!save.project_id">Klant
|
||||
<select class="form-input" x-model="save.client_id">
|
||||
<option value="">—</option>
|
||||
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||||
</select></label>
|
||||
</div>
|
||||
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">Bij opslaan: SVG, PNG, PDF + manifest → <code>…/packaging/</code> op NAS.</p>
|
||||
<div x-show="nasInfo.ok" class="pack-nas-badge" style="margin-top:0.5rem">✓ NAS export OK</div>
|
||||
<div class="muted" style="font-size:0.68rem;margin-top:0.35rem;word-break:break-all" x-show="nasInfo.files?.svg" x-text="nasInfo.files?.svg"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="pack-panel pack-preview-wrap">
|
||||
<h3>Live preview</h3>
|
||||
<div class="pack-preview-stage">
|
||||
<div x-show="svg" x-html="svg"></div>
|
||||
<p class="page-tip" x-show="!svg && !loading">Klik Preview of pas instellingen aan.</p>
|
||||
<p class="page-tip" x-show="loading">Genereren…</p>
|
||||
</div>
|
||||
<div class="pack-meta-bar">
|
||||
<span x-text="packagingId ? 'ID: '+packagingId.substring(0,8)+'…' : 'Nog niet opgeslagen'"></span>
|
||||
<span x-text="form.type + ' · ' + form.width_mm + '×' + form.height_mm + '×' + form.depth_mm + ' mm'"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2>
|
||||
<div class="project-list">
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<div class="project-item">
|
||||
<strong x-text="p.spec.type"></strong>
|
||||
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small>
|
||||
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small>
|
||||
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button>
|
||||
</div>
|
||||
<aside class="pack-panel">
|
||||
<h3>Bibliotheek</h3>
|
||||
<div class="pack-library">
|
||||
<template x-for="p in library" :key="p.id">
|
||||
<article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
|
||||
<strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
|
||||
<small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
|
||||
<small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
|
||||
</article>
|
||||
</template>
|
||||
<p class="page-tip" x-show="!projects.length">Nog geen packaging projecten.</p>
|
||||
<p class="page-tip" x-show="!library.length">Nog geen designs opgeslagen.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:1rem;">
|
||||
<div class="pack-preview">
|
||||
<div x-show="svg" x-html="svg"></div>
|
||||
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p>
|
||||
<div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
|
||||
<div class="pack-modal" x-show="showEmailModal" x-cloak>
|
||||
<h3>✉ Design emailen</h3>
|
||||
<label>Aan (komma-gescheiden)<input class="form-input" x-model="emailForm.to" placeholder="klant@example.com" /></label>
|
||||
<label style="margin-top:0.5rem;display:block">Onderwerp<input class="form-input" x-model="emailForm.subject" /></label>
|
||||
<label style="margin-top:0.5rem;display:block">Bericht<textarea class="form-input" rows="4" x-model="emailForm.body"></textarea></label>
|
||||
<p class="page-tip" style="font-size:0.7rem;margin-top:0.5rem">Downloadlinks voor SVG, PNG en PDF worden automatisch toegevoegd.</p>
|
||||
<div class="pack-modal-actions">
|
||||
<button class="btn btn-sm" @click="showEmailModal=false">Annuleer</button>
|
||||
<button class="btn btn-primary btn-sm" @click="sendEmail()">Verstuur</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pack-modal-backdrop" x-show="showCopyModal" x-cloak @click="showCopyModal=false"></div>
|
||||
<div class="pack-modal" x-show="showCopyModal" x-cloak>
|
||||
<h3>📁 Kopieer naar ander project</h3>
|
||||
<label>Doelproject
|
||||
<select class="form-input" x-model="copyTargetId">
|
||||
<option value="">— Selecteer —</option>
|
||||
<template x-for="p in cockpitProjects" :key="'cp'+p.id">
|
||||
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
|
||||
</template>
|
||||
</select></label>
|
||||
<div class="pack-modal-actions">
|
||||
<button class="btn btn-sm" @click="showCopyModal=false">Annuleer</button>
|
||||
<button class="btn btn-primary btn-sm" @click="copyToProject()">Kopieer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,58 +175,205 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function packagingStudio() {
|
||||
const DEFAULT_BRAND = {
|
||||
bg: '#0b1220', panel: '#101a2d', primary: '#00e5ff', secondary: '#ffd700',
|
||||
accent: '#b8ff3c', text: '#e2e8f0', muted: '#94a3b8', cut_line: '#ef4444',
|
||||
fold_line: '#60a5fa', bleed: '#f97316', halal: '#22c55e'
|
||||
};
|
||||
const DEFAULT_ELEMENTS = {
|
||||
barcode: true, logo_area: true, fold_lines: true, cut_lines: true,
|
||||
nutrition_panel: false, ingredients: true, halal_badge: false, window: false,
|
||||
qr_code: false, glue_tabs: false, bleed: true, dimensions: true
|
||||
};
|
||||
return {
|
||||
loading: false,
|
||||
sideTab: 'shape',
|
||||
svg: '',
|
||||
projectId: '',
|
||||
projects: [],
|
||||
packagingId: '',
|
||||
library: [],
|
||||
cockpitProjects: [],
|
||||
clients: [],
|
||||
types: [],
|
||||
nasInfo: {},
|
||||
showEmailModal: false,
|
||||
showCopyModal: false,
|
||||
copyTargetId: '',
|
||||
previewTimer: null,
|
||||
save: { project_name: '', client_id: '', project_id: '{{ initial_project_id or "" }}' },
|
||||
emailForm: { to: '', subject: 'Foodlinkk packaging design', body: 'Bijgevoegd vind je ons packaging design.' },
|
||||
colorFields: [
|
||||
{ key: 'primary', label: 'Primair' }, { key: 'secondary', label: 'Secundair' },
|
||||
{ key: 'accent', label: 'Accent' }, { key: 'panel', label: 'Paneel' },
|
||||
{ key: 'text', label: 'Tekst' }, { key: 'bg', label: 'Achtergrond' }
|
||||
],
|
||||
elementList: [
|
||||
{ key: 'logo_area', label: 'Logo zone' }, { key: 'barcode', label: 'Barcode' },
|
||||
{ key: 'qr_code', label: 'QR code' }, { key: 'nutrition_panel', label: 'Voedingswaarden' },
|
||||
{ key: 'ingredients', label: 'Ingrediënten' }, { key: 'halal_badge', label: 'Halal badge' },
|
||||
{ key: 'window', label: 'Venster' }, { key: 'fold_lines', label: 'Vouwlijnen' },
|
||||
{ key: 'cut_lines', label: 'Snijlijnen' }, { key: 'glue_tabs', label: 'Lijmtabs' },
|
||||
{ key: 'bleed', label: 'Bleed marge' }, { key: 'dimensions', label: 'Afmetingen' }
|
||||
],
|
||||
form: {
|
||||
type: 'folding_box',
|
||||
width_mm: 120,
|
||||
height_mm: 80,
|
||||
depth_mm: 40,
|
||||
barcode_value: '8710000000012',
|
||||
brand: {},
|
||||
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true }
|
||||
width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
|
||||
barcode_value: '8710000000012', qr_value: '',
|
||||
design_name: 'Nieuw design',
|
||||
brand: { ...DEFAULT_BRAND },
|
||||
text: {
|
||||
product_name: 'FOODLINKK Premium',
|
||||
tagline: 'Authentiek · Vers · Halal',
|
||||
subtitle: '400g', origin: 'Geproduceerd in NL',
|
||||
ingredients: 'Kip 45%, water, specerijen, zout, ui, knoflook.',
|
||||
best_before: 'Ten minste houdbaar tot: zie verpakking'
|
||||
},
|
||||
elements: { ...DEFAULT_ELEMENTS }
|
||||
},
|
||||
async init() { await this.refreshProjects(); },
|
||||
downloadUrl(format) {
|
||||
if (!this.projectId) return '#';
|
||||
return '/api/packaging/download/' + this.projectId + '?format=' + format;
|
||||
},
|
||||
async refreshProjects() {
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/projects?limit=20');
|
||||
this.projects = r.items || [];
|
||||
const r = await Cockpit.api('/clients');
|
||||
this.clients = r.clients || r.items || [];
|
||||
} catch (e) { this.clients = []; }
|
||||
const pid = new URLSearchParams(location.search).get('project_id') || this.save.project_id;
|
||||
if (pid) this.save.project_id = String(pid);
|
||||
await this.preview();
|
||||
},
|
||||
|
||||
async loadTypes() {
|
||||
try {
|
||||
const r = await fetch('/api/packaging/types').then(x => x.json());
|
||||
this.types = r.items || [];
|
||||
} catch (e) {
|
||||
this.projects = [];
|
||||
this.types = [
|
||||
{ id: 'folding_box', label: 'Folding box' }, { id: 'wrap', label: 'Wrap' },
|
||||
{ id: 'round_label', label: 'Rond label' }, { id: 'sleeve', label: 'Sleeve' },
|
||||
{ id: 'pouch', label: 'Pouch' }, { id: 'tray', label: 'Tray' }
|
||||
];
|
||||
}
|
||||
},
|
||||
async loadProject(id) {
|
||||
this.projectId = id;
|
||||
try {
|
||||
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text());
|
||||
this.svg = data;
|
||||
} catch (e) {
|
||||
Cockpit.toast('Kon project niet laden', 'error');
|
||||
|
||||
payload() {
|
||||
const body = JSON.parse(JSON.stringify(this.form));
|
||||
if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
|
||||
else if (this.save.project_name) {
|
||||
body.project_name = this.save.project_name;
|
||||
if (this.save.client_id) body.client_id = parseInt(this.save.client_id, 10);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
async generate() {
|
||||
|
||||
debouncedPreview() {
|
||||
clearTimeout(this.previewTimer);
|
||||
this.previewTimer = setTimeout(() => this.preview(), 450);
|
||||
},
|
||||
|
||||
async preview() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
|
||||
this.svg = r.svg || '';
|
||||
this.projectId = r.id || '';
|
||||
await this.refreshProjects();
|
||||
Cockpit.toast('Packaging gegenereerd', 'success');
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
|
||||
}
|
||||
} catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const url = this.packagingId ? '/api/packaging/' + this.packagingId : '/api/packaging/generate';
|
||||
const method = this.packagingId ? 'PATCH' : 'POST';
|
||||
const r = await Cockpit.api(url, { method, body: JSON.stringify(this.payload()) });
|
||||
this.svg = r.svg || this.svg;
|
||||
this.packagingId = r.id || this.packagingId;
|
||||
this.nasInfo = r.nas || {};
|
||||
await this.refreshLibrary();
|
||||
await this.loadCockpitProjects();
|
||||
Cockpit.toast(this.packagingId ? 'Design bijgewerkt' : 'Design opgeslagen', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message || 'Opslaan mislukt', 'error'); }
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async duplicate() {
|
||||
if (!this.packagingId) return;
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/duplicate', { method: 'POST', body: '{}' });
|
||||
await this.loadDesign(r.id);
|
||||
Cockpit.toast('Design gedupliceerd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async copyToProject() {
|
||||
if (!this.packagingId || !this.copyTargetId) return Cockpit.toast('Selecteer doelproject', 'warn');
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/copy-to-project', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target_project_id: parseInt(this.copyTargetId, 10) })
|
||||
});
|
||||
this.showCopyModal = false;
|
||||
await this.loadDesign(r.id);
|
||||
Cockpit.toast('Gekopieerd naar project', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async sendEmail() {
|
||||
if (!this.packagingId) return;
|
||||
const to = (this.emailForm.to || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!to.length) return Cockpit.toast('Vul ontvanger in', 'warn');
|
||||
try {
|
||||
await Cockpit.api('/api/packaging/' + this.packagingId + '/email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...this.emailForm, to })
|
||||
});
|
||||
this.showEmailModal = false;
|
||||
Cockpit.toast('Email verstuurd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message || 'Email mislukt — check Settings → Email', 'error'); }
|
||||
},
|
||||
|
||||
newDesign() {
|
||||
this.packagingId = '';
|
||||
this.nasInfo = {};
|
||||
this.form.design_name = 'Nieuw design';
|
||||
this.preview();
|
||||
},
|
||||
|
||||
downloadUrl(format) {
|
||||
return this.packagingId ? '/api/packaging/download/' + this.packagingId + '?format=' + format : '#';
|
||||
},
|
||||
|
||||
async refreshLibrary() {
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/projects?limit=40');
|
||||
this.library = r.items || [];
|
||||
} catch (e) { this.library = []; }
|
||||
},
|
||||
|
||||
async loadCockpitProjects() {
|
||||
try {
|
||||
const r = await fetch('/api/projects?limit=80').then(x => x.json());
|
||||
this.cockpitProjects = r.items || [];
|
||||
} catch (e) { this.cockpitProjects = []; }
|
||||
},
|
||||
|
||||
async loadDesign(id) {
|
||||
try {
|
||||
const r = await Cockpit.api('/api/packaging/' + id);
|
||||
const item = r.item || {};
|
||||
const spec = item.spec || {};
|
||||
this.packagingId = item.id;
|
||||
this.svg = item.svg || '';
|
||||
if (spec.type) this.form.type = spec.type;
|
||||
['width_mm','height_mm','depth_mm','bleed_mm','barcode_value','qr_value','design_name'].forEach(k => {
|
||||
if (spec[k] !== undefined) this.form[k] = spec[k];
|
||||
});
|
||||
this.form.brand = { ...DEFAULT_BRAND, ...(spec.brand || {}) };
|
||||
this.form.text = { ...this.form.text, ...(spec.text || {}) };
|
||||
this.form.elements = { ...DEFAULT_ELEMENTS, ...(spec.elements || {}) };
|
||||
if (item.cockpit_project_id) this.save.project_id = String(item.cockpit_project_id);
|
||||
Cockpit.toast('Design geladen', 'info');
|
||||
} catch (e) { Cockpit.toast('Laden mislukt', 'error'); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+247
-27
@@ -1,40 +1,260 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="productsCrud()">
|
||||
<header class="page-header"><h1>Products</h1><p class="subtitle page-tip">SKU catalog with margin calculator.</p></header>
|
||||
<button class="btn btn-primary" @click="show=true; id=null">Add product</button>
|
||||
<section class="panel"><h2>Margin calculator</h2>
|
||||
<div class="form-grid"><input type="number" class="form-input" x-model="margin.cost" placeholder="Cost" />
|
||||
<input type="number" class="form-input" x-model="margin.sell" placeholder="Sell" />
|
||||
<button class="btn btn-secondary" @click="calcMargin()">Calculate</button></div>
|
||||
<p x-show="margin.result">Margin: <strong x-text="margin.result"></strong>%</p></section>
|
||||
<table class="data-table"><thead><tr><th>Name</th><th>Status</th><th>Margin %</th><th></th></tr></thead>
|
||||
<tbody>{% for p in products %}<tr class="clickable-row" @click="edit({{ p.id }}, '{{ p.name|e }}', '{{ p.status|e }}', {{ p.margin_pct or 0 }})">
|
||||
<td>{{ p.name }}</td><td>{{ p.status }}</td><td>{{ p.margin_pct }}</td>
|
||||
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ p.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
|
||||
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.status" /><input type="number" class="form-input" x-model="form.margin_pct" />
|
||||
<button class="btn btn-primary" @click="save()">Save</button></div></div>
|
||||
<div class="clients-shell" x-data="productsHub()" x-init="init()" @foodlinkk:locale.window="onLocale()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 x-text="$t('products.title')">Productcatalogus</h1>
|
||||
<p class="subtitle page-tip" x-text="$t('products.subtitle')"></p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
|
||||
<a class="btn btn-sm" href="/clients" x-text="$t('nav.clients')"></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="clients-kpi-row">
|
||||
<div class="clients-kpi"><span x-text="$t('products.total')"></span><strong x-text="stats.total||0"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('products.active')"></span><strong x-text="stats.active||0"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('products.avg_margin')"></span><strong x-text="(stats.avg_margin||0)+'%'"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('products.with_client')"></span><strong x-text="stats.with_client||0"></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="clients-tabs">
|
||||
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('products.tab_grid')"></button>
|
||||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('products.tab_list')"></button>
|
||||
<button class="clients-tab" :class="{active: tab==='margin'}" @click="tab='margin'" x-text="$t('products.tab_margin')"></button>
|
||||
<div class="clients-tab-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('products.new')"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='grid'" x-cloak>
|
||||
<div class="agents-neo-grid">
|
||||
<template x-for="p in filtered" :key="p.id">
|
||||
<article class="agent-soul-card" @click="openDetail(p)">
|
||||
<strong x-text="p.name"></strong>
|
||||
<div class="clients-card-meta">
|
||||
<span class="clients-tag" x-text="statusLabel(p.status)"></span>
|
||||
<span class="clients-tag mrr" x-show="p.margin_pct" x-text="(p.margin_pct||0)+'%'"></span>
|
||||
</div>
|
||||
<div class="clients-card-meta" x-show="p.client_name" x-text="p.client_name"></div>
|
||||
<div class="clients-card-meta" x-show="p.shelf_target" x-text="p.shelf_target"></div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<p class="clients-empty" x-show="!filtered.length" x-text="$t('products.no_products')"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='list'" x-cloak>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
|
||||
<select x-model="filterStatus" @change="applyFilter()">
|
||||
<option value="" x-text="$t('common.all')"></option>
|
||||
<template x-for="s in statuses" :key="s.id">
|
||||
<option :value="s.id" x-text="s.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th x-text="$t('products.name')"></th><th x-text="$t('products.client')"></th>
|
||||
<th x-text="$t('products.status')"></th><th x-text="$t('products.margin_pct')"></th>
|
||||
<th x-text="$t('products.moq')"></th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<template x-for="p in filtered" :key="'l'+p.id">
|
||||
<tr class="clickable-row" @click="openDetail(p)">
|
||||
<td><strong x-text="p.name"></strong></td>
|
||||
<td x-text="p.client_name||'—'"></td>
|
||||
<td><span class="badge" x-text="statusLabel(p.status)"></span></td>
|
||||
<td x-text="p.margin_pct!=null?p.margin_pct+'%':'—'"></td>
|
||||
<td x-text="p.moq||'—'"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm" @click.stop="editProduct(p)" x-text="$t('common.edit')"></button>
|
||||
<button class="btn btn-sm btn-reject" @click.stop="remove(p.id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='margin'" x-cloak>
|
||||
<section class="panel store-edit-panel">
|
||||
<div class="clients-form-grid" style="max-width:480px">
|
||||
<label><span class="filter-label" x-text="$t('products.calc_cost')"></span>
|
||||
<input type="number" class="form-input" x-model="margin.cost" step="0.01" /></label>
|
||||
<label><span class="filter-label" x-text="$t('products.calc_sell')"></span>
|
||||
<input type="number" class="form-input" x-model="margin.sell" step="0.01" /></label>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-top:0.75rem" @click="calcMargin()" x-text="$t('products.calc_run')"></button>
|
||||
<div x-show="margin.result!=null" style="margin-top:1rem">
|
||||
<p><span x-text="$t('products.calc_margin')"></span>: <strong x-text="margin.result+'%'"></strong></p>
|
||||
<p x-show="margin.profit!=null"><span x-text="$t('products.calc_profit')"></span>: <strong x-text="'€'+margin.profit"></strong></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<h3 x-text="form.id ? $t('common.edit') : $t('products.new')"></h3>
|
||||
<div class="clients-form-grid">
|
||||
<label class="full"><span class="filter-label" x-text="$t('products.name')"></span>
|
||||
<input class="form-input" x-model="form.name" /></label>
|
||||
<label><span class="filter-label" x-text="$t('products.status')"></span>
|
||||
<select class="form-input" x-model="form.status">
|
||||
<template x-for="s in statuses" :key="'fs'+s.id"><option :value="s.id" x-text="s.label"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label" x-text="$t('products.client')"></span>
|
||||
<select class="form-input" x-model="form.client_id">
|
||||
<option value="" x-text="$t('common.none')"></option>
|
||||
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label" x-text="$t('products.margin_pct')"></span>
|
||||
<input type="number" class="form-input" x-model="form.margin_pct" step="0.1" /></label>
|
||||
<label><span class="filter-label" x-text="$t('products.moq')"></span>
|
||||
<input type="number" class="form-input" x-model="form.moq" /></label>
|
||||
<label><span class="filter-label" x-text="$t('products.launch_date')"></span>
|
||||
<input type="date" class="form-input" x-model="form.launch_date" /></label>
|
||||
<label class="full"><span class="filter-label" x-text="$t('products.shelf_target')"></span>
|
||||
<input class="form-input" x-model="form.shelf_target" placeholder="Jumbo NL · AH regio" /></label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
|
||||
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="clients-drawer" :class="{ open: drawer }">
|
||||
<div class="clients-drawer-head">
|
||||
<div><h2 x-text="detail?.product?.name||''"></h2>
|
||||
<p class="muted" x-text="statusLabel(detail?.product?.status)"></p></div>
|
||||
<button class="drawer-close" @click="drawer=false">×</button>
|
||||
</div>
|
||||
<template x-if="detail?.product">
|
||||
<div class="clients-card-meta">
|
||||
<div x-show="detail.product.client_name"><strong x-text="$t('products.drawer_deals')"></strong>: <span x-text="detail.product.client_name"></span></div>
|
||||
<div x-show="detail.product.margin_pct"><strong x-text="$t('products.margin_pct')"></strong>: <span x-text="detail.product.margin_pct+'%'"></span></div>
|
||||
<div x-show="detail.product.moq"><strong x-text="$t('products.moq')"></strong>: <span x-text="detail.product.moq"></span></div>
|
||||
<div x-show="detail.product.shelf_target"><strong x-text="$t('products.shelf_target')"></strong>: <span x-text="detail.product.shelf_target"></span></div>
|
||||
<div x-show="detail.product.launch_date"><strong x-text="$t('products.launch_date')"></strong>: <span x-text="detail.product.launch_date"></span></div>
|
||||
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editProduct(detail.product)" x-text="$t('common.edit')"></button>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function productsCrud() {
|
||||
function productsHub() {
|
||||
return {
|
||||
show: false, id: null, form: { name: '', status: 'concept', margin_pct: 0, client_id: null },
|
||||
margin: { cost: 0, sell: 0, result: null },
|
||||
edit(id, name, status, margin_pct) { this.id=id; this.form={name,status,margin_pct,client_id:null}; this.show=true; },
|
||||
async calcMargin() {
|
||||
const r = await Cockpit.api('/products/margin-calc', { method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }) });
|
||||
this.margin.result = r.margin_pct;
|
||||
tab: 'grid', busy: false, modal: false, drawer: false,
|
||||
stats: {}, products: [], filtered: [], clients: [], detail: null,
|
||||
filterQ: '', filterStatus: '',
|
||||
form: {}, margin: { cost: 0, sell: 0, result: null, profit: null },
|
||||
statuses: [],
|
||||
|
||||
onLocale() { this.buildStatuses(); },
|
||||
buildStatuses() {
|
||||
this.statuses = [
|
||||
{ id: 'concept', label: this.$t('products.status_concept') },
|
||||
{ id: 'development', label: this.$t('products.status_development') },
|
||||
{ id: 'active', label: this.$t('products.status_active') },
|
||||
{ id: 'discontinued', label: this.$t('products.status_discontinued') },
|
||||
];
|
||||
},
|
||||
statusLabel(id) {
|
||||
return (this.statuses.find(s => s.id === id) || {}).label || id || '—';
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.buildStatuses();
|
||||
document.addEventListener('foodlinkk:locale', () => this.buildStatuses());
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
try {
|
||||
const [statsR, prodR, cliR] = await Promise.all([
|
||||
Cockpit.api('/products/stats'),
|
||||
Cockpit.api('/products'),
|
||||
Cockpit.api('/clients'),
|
||||
]);
|
||||
this.stats = statsR.stats || {};
|
||||
this.products = prodR.items || [];
|
||||
this.clients = cliR.items || [];
|
||||
this.applyFilter();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
applyFilter() {
|
||||
const q = (this.filterQ || '').toLowerCase();
|
||||
this.filtered = this.products.filter(p => {
|
||||
if (this.filterStatus && p.status !== this.filterStatus) return false;
|
||||
if (!q) return true;
|
||||
return [p.name, p.client_name, p.shelf_target, p.status].some(
|
||||
f => f && String(f).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
openModal() {
|
||||
this.form = { id: null, name: '', status: 'concept', client_id: '', margin_pct: '', moq: '', shelf_target: '', launch_date: '' };
|
||||
this.modal = true;
|
||||
},
|
||||
editProduct(p) {
|
||||
this.form = {
|
||||
id: p.id, name: p.name || '', status: p.status || 'concept',
|
||||
client_id: p.client_id || '', margin_pct: p.margin_pct ?? '',
|
||||
moq: p.moq ?? '', shelf_target: p.shelf_target || '', launch_date: p.launch_date || '',
|
||||
};
|
||||
this.modal = true; this.drawer = false;
|
||||
},
|
||||
|
||||
async save() {
|
||||
const body = { ...this.form, margin_pct: parseFloat(this.form.margin_pct) };
|
||||
if (this.id) await Cockpit.api('/products/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
const body = {
|
||||
name: this.form.name.trim(),
|
||||
status: this.form.status,
|
||||
client_id: this.form.client_id ? parseInt(this.form.client_id, 10) : null,
|
||||
margin_pct: this.form.margin_pct !== '' ? parseFloat(this.form.margin_pct) : null,
|
||||
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
|
||||
shelf_target: this.form.shelf_target || null,
|
||||
launch_date: this.form.launch_date || null,
|
||||
};
|
||||
try {
|
||||
if (this.form.id) await Cockpit.api('/products/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
|
||||
Cockpit.toast(window.I18n.t('common.saved'), 'success');
|
||||
this.modal = false;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
if (!Cockpit.confirmDelete()) return;
|
||||
await Cockpit.api('/products/' + id, { method: 'DELETE' });
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async openDetail(p) {
|
||||
const d = await Cockpit.api('/products/' + p.id + '/detail');
|
||||
this.detail = d;
|
||||
this.drawer = true;
|
||||
},
|
||||
|
||||
async calcMargin() {
|
||||
const r = await Cockpit.api('/products/margin-calc', {
|
||||
method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }),
|
||||
});
|
||||
this.margin.result = r.margin_pct;
|
||||
this.margin.profit = r.profit;
|
||||
},
|
||||
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/products/' + id, { method: 'DELETE' }); location.reload(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/projects-hub.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="clients-shell" x-data="projectsHub()" x-init="init()">
|
||||
<header class="page-header herman-hero">
|
||||
<div>
|
||||
<h1>Projecten <span class="live-badge">Food Business</span></h1>
|
||||
<p class="hero-sub">Gestructureerd per klant · NAS-mappen · foto's · documenten · packaging</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
|
||||
<a class="btn btn-sm" href="/documents">Documenten →</a>
|
||||
<a class="btn btn-sm" href="/packaging">Packaging →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="clients-kpi-row" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="clients-kpi"><span>Totaal projecten</span><strong x-text="stats.total||0"></strong></div>
|
||||
<div class="clients-kpi"><span>Met klant</span><strong x-text="stats.with_client||0"></strong></div>
|
||||
<div class="clients-kpi"><span>NAS mappen</span><strong x-text="stats.with_nas||0"></strong></div>
|
||||
<div class="clients-kpi"><span>Assets gekoppeld</span><strong x-text="stats.assets||0"></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="clients-tabs">
|
||||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">📋 Overzicht</button>
|
||||
<button class="clients-tab" :class="{active: tab==='create'}" @click="tab='create'">➕ Nieuw project</button>
|
||||
<button class="clients-tab" :class="{active: tab==='byclient'}" @click="tab='byclient'">👥 Per klant</button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='create'" x-cloak>
|
||||
<section class="panel store-edit-panel">
|
||||
<h2 style="margin-top:0">Nieuw food-business project</h2>
|
||||
<p class="page-tip">Kies een type — per klant wordt automatisch een aparte NAS-map aangemaakt (niet alles op één hoop).</p>
|
||||
<div class="project-type-grid">
|
||||
<template x-for="t in types" :key="t.id">
|
||||
<div class="project-type-card" :class="{selected: form.project_type===t.id}" @click="form.project_type=t.id">
|
||||
<span class="icon" x-text="t.icon"></span>
|
||||
<span class="label" x-text="t.label_nl"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="clients-form-grid">
|
||||
<label class="full"><span class="filter-label">Projectnaam *</span>
|
||||
<input class="form-input" x-model="form.name" placeholder="bv. Jumbo listing halal maaltijden Q3" /></label>
|
||||
<label><span class="filter-label">Klant *</span>
|
||||
<select class="form-input" x-model="form.client_id">
|
||||
<option value="">— Selecteer klant —</option>
|
||||
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label">Prioriteit</span>
|
||||
<select class="form-input" x-model="form.priority">
|
||||
<option value="low">Laag</option><option value="normal">Normaal</option>
|
||||
<option value="high">Hoog</option><option value="urgent">Urgent</option>
|
||||
</select></label>
|
||||
<label class="full"><span class="filter-label">Beschrijving</span>
|
||||
<textarea class="form-input" rows="3" x-model="form.description" placeholder="Doel, deliverables, deadline…"></textarea></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="create()" :disabled="!form.name.trim()">Project + NAS-map aanmaken</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='list'" x-cloak>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" placeholder="Zoek project…" x-model="filterQ" @input="applyFilter()" />
|
||||
<select x-model="filterType" @change="applyFilter()">
|
||||
<option value="">Alle types</option>
|
||||
<template x-for="t in types" :key="'f'+t.id"><option :value="t.id" x-text="t.label_nl"></option></template>
|
||||
</select>
|
||||
<select x-model="filterClient" @change="applyFilter()">
|
||||
<option value="">Alle klanten</option>
|
||||
<template x-for="c in clients" :key="'fc'+c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||||
</select>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Naam</th><th>Type</th><th>Klant</th><th>Assets</th><th>NAS</th><th>Status</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<template x-for="p in filtered" :key="p.id">
|
||||
<tr class="clickable-row" @click="open(p.id)">
|
||||
<td><strong x-text="p.name"></strong></td>
|
||||
<td x-text="typeLabel(p.project_type)"></td>
|
||||
<td x-text="p.client_name||'—'"></td>
|
||||
<td x-text="p.asset_count||0"></td>
|
||||
<td><span class="clients-tag" x-show="p.nas_path">✓ map</span><span x-show="!p.nas_path">—</span></td>
|
||||
<td x-text="p.status"></td>
|
||||
<td><button class="btn btn-sm" @click.stop="open(p.id)">Open</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='byclient'" x-cloak>
|
||||
<template x-for="c in clientsWithProjects" :key="c.id">
|
||||
<section class="panel" style="margin-bottom:0.75rem">
|
||||
<h3 x-text="c.name"></h3>
|
||||
<p class="muted" style="font-size:0.75rem" x-show="c.nas_folder" x-text="'NAS: '+c.nas_folder"></p>
|
||||
<template x-for="p in c.projects" :key="p.id">
|
||||
<div class="feed-card clickable-row" @click="open(p.id)">
|
||||
<strong x-text="p.name"></strong>
|
||||
<small x-text="typeLabel(p.project_type)+' · '+(p.asset_count||0)+' assets'"></small>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!c.projects.length" class="muted">Geen projecten — maak er een aan voor deze klant.</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<aside class="clients-drawer" :class="{ open: drawer }" style="width:min(560px,96vw)">
|
||||
<div class="clients-drawer-head">
|
||||
<div>
|
||||
<h2 x-text="selected?.name||''"></h2>
|
||||
<p class="muted" x-text="typeLabel(selected?.project_type)+' · '+(selected?.client_name||'Geen klant')"></p>
|
||||
</div>
|
||||
<button class="drawer-close" @click="drawer=false">×</button>
|
||||
</div>
|
||||
<template x-if="selected">
|
||||
<div>
|
||||
<div class="clients-tabs" style="margin-bottom:0.75rem">
|
||||
<button class="clients-tab" :class="{active: detailTab==='info'}" @click="detailTab='info'">Info</button>
|
||||
<button class="clients-tab" :class="{active: detailTab==='assets'}" @click="detailTab='assets'">Assets</button>
|
||||
<button class="clients-tab" :class="{active: detailTab==='photos'}" @click="detailTab='photos'; loadPhotos()">Foto's</button>
|
||||
<button class="clients-tab" :class="{active: detailTab==='files'}" @click="detailTab='files'; loadNasFiles()">NAS files</button>
|
||||
</div>
|
||||
|
||||
<div x-show="detailTab==='info'">
|
||||
<p x-text="selected.description||'Geen beschrijving'"></p>
|
||||
<div x-show="selected.nas_path">
|
||||
<strong style="font-size:0.72rem;color:#94a3b8">NAS projectmap</strong>
|
||||
<div class="nas-path-box" x-text="selected.nas_path"></div>
|
||||
<p class="muted" style="font-size:0.7rem">Structuur: photos/ · documents/ · packaging/ · exports/ · briefs/</p>
|
||||
</div>
|
||||
<div x-show="selected.nas_client_root">
|
||||
<strong style="font-size:0.72rem;color:#94a3b8">Klant NAS root</strong>
|
||||
<div class="nas-path-box" x-text="selected.nas_client_root"></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:0.75rem">
|
||||
<button class="btn btn-sm" @click="ensureNas()" x-show="!selected.nas_path">NAS-map aanmaken</button>
|
||||
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Packaging Studio</a>
|
||||
<a class="btn btn-sm" href="/documents">Documenten hub →</a>
|
||||
<a class="btn btn-sm" href="/clients">Klant CRM →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="detailTab==='assets'">
|
||||
<div class="asset-grid">
|
||||
<template x-for="a in (selected.assets||[])" :key="a.id">
|
||||
<div class="asset-card">
|
||||
<div class="type" x-text="a.asset_type"></div>
|
||||
<strong x-text="a.title"></strong>
|
||||
<small x-text="(a.source_agent||a.created_by||'')+' · '+(a.created_at||'').substring(0,10)"></small>
|
||||
<div class="muted" style="font-size:0.68rem;word-break:break-all" x-show="a.file_path" x-text="a.file_path"></div>
|
||||
<div class="btn-group" style="margin-top:0.45rem" x-show="a.asset_type==='packaging' && a.ref_id">
|
||||
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Open in Studio</a>
|
||||
<a class="btn btn-sm btn-ghost" :href="'/api/packaging/download/'+a.ref_id+'?format=pdf'" target="_blank">PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!(selected.assets||[]).length" class="muted">Nog geen assets — koppel foto's of NAS-bestanden.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="detailTab==='photos'">
|
||||
<p class="page-tip">Klik een foto om te koppelen aan dit project.</p>
|
||||
<div class="photo-pick-grid">
|
||||
<template x-for="ph in photos" :key="ph.id">
|
||||
<div class="photo-pick-item" @click="linkPhoto(ph.id)">
|
||||
<img :src="'/api/admin/photos/' + ph.id + '/image.jpg'" :alt="ph.filename" loading="lazy" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!photos.length" class="muted">Geen foto's — gebruik Browser/Photos om te importeren.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="detailTab==='files'">
|
||||
<p class="page-tip">Koppel een NAS-bestand aan dit project (gestructureerd in projectmap).</p>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" placeholder="Zoek bestand…" x-model="fileQ" @input="filterNasFiles()" />
|
||||
</div>
|
||||
<template x-for="f in nasFilesFiltered.slice(0,40)" :key="f.storage_path||f.path">
|
||||
<div class="feed-card clickable-row" @click="linkNasFile(f)">
|
||||
<strong x-text="f.filename||f.name"></strong>
|
||||
<small x-text="f.storage_path||f.path||''"></small>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!nasFiles.length" class="muted">Geen NAS-bestanden gevonden — check Documenten hub.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function projectsHub() {
|
||||
return {
|
||||
tab: 'list', detailTab: 'info', busy: false, drawer: false,
|
||||
stats: {}, projects: [], filtered: [], clients: [], types: [],
|
||||
selected: null, photos: [], nasFiles: [], nasFilesFiltered: [], fileQ: '',
|
||||
filterQ: '', filterType: '', filterClient: '',
|
||||
form: { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' },
|
||||
|
||||
get clientsWithProjects() {
|
||||
return this.clients.map(c => ({
|
||||
...c,
|
||||
projects: this.projects.filter(p => p.client_id === c.id),
|
||||
})).filter(c => c.projects.length || true);
|
||||
},
|
||||
|
||||
typeLabel(id) {
|
||||
return (this.types.find(t => t.id === id) || {}).label_nl || id || '—';
|
||||
},
|
||||
|
||||
async init() {
|
||||
const q = new URLSearchParams(location.search).get('id');
|
||||
await Promise.all([this.loadAll(), this.loadClients(), this.loadTypes()]);
|
||||
if (q) this.open(parseInt(q, 10));
|
||||
},
|
||||
|
||||
async loadTypes() {
|
||||
const r = await fetch('/api/projects/types').then(x => x.json());
|
||||
this.types = r.items || [];
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
try {
|
||||
const [statsR, projR] = await Promise.all([
|
||||
fetch('/api/projects/stats').then(x => x.json()),
|
||||
fetch('/api/projects?limit=200').then(x => x.json()),
|
||||
]);
|
||||
this.stats = statsR.stats || {};
|
||||
this.projects = projR.items || [];
|
||||
this.applyFilter();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
async loadClients() {
|
||||
try {
|
||||
const r = await Cockpit.api('/clients');
|
||||
this.clients = r.items || [];
|
||||
} catch (e) { this.clients = []; }
|
||||
},
|
||||
|
||||
applyFilter() {
|
||||
const q = (this.filterQ || '').toLowerCase();
|
||||
this.filtered = this.projects.filter(p => {
|
||||
if (this.filterType && p.project_type !== this.filterType) return false;
|
||||
if (this.filterClient && String(p.client_id) !== String(this.filterClient)) return false;
|
||||
if (!q) return true;
|
||||
return [p.name, p.client_name, p.description].some(f => f && String(f).toLowerCase().includes(q));
|
||||
});
|
||||
},
|
||||
|
||||
async create() {
|
||||
if (!this.form.name.trim()) return Cockpit.toast('Naam verplicht', 'error');
|
||||
if (!this.form.client_id) return Cockpit.toast('Selecteer een klant voor NAS-structuur', 'warn');
|
||||
await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
client_id: parseInt(this.form.client_id, 10),
|
||||
description: this.form.description,
|
||||
project_type: this.form.project_type,
|
||||
priority: this.form.priority,
|
||||
}),
|
||||
}).then(r => { if (!r.ok) throw new Error('Aanmaken mislukt'); return r.json(); });
|
||||
this.form = { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' };
|
||||
this.tab = 'list';
|
||||
await this.loadAll();
|
||||
Cockpit.toast('Project + NAS-map aangemaakt', 'success');
|
||||
},
|
||||
|
||||
async open(id) {
|
||||
const r = await fetch('/api/projects/' + id).then(x => x.json());
|
||||
this.selected = r.project;
|
||||
this.detailTab = 'info';
|
||||
this.drawer = true;
|
||||
},
|
||||
|
||||
async ensureNas() {
|
||||
if (!this.selected) return;
|
||||
const r = await fetch('/api/projects/' + this.selected.id + '/ensure-nas', { method: 'POST' }).then(x => x.json());
|
||||
Cockpit.toast('NAS-map aangemaakt', 'success');
|
||||
await this.open(this.selected.id);
|
||||
},
|
||||
|
||||
async loadPhotos() {
|
||||
try {
|
||||
const r = await Cockpit.api('/photos?limit=60');
|
||||
this.photos = r.items || r.photos || [];
|
||||
} catch (e) { this.photos = []; }
|
||||
},
|
||||
|
||||
async linkPhoto(photoId) {
|
||||
if (!this.selected) return;
|
||||
await Cockpit.api('/api/projects/photos/' + photoId + '/link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project_id: this.selected.id }),
|
||||
});
|
||||
Cockpit.toast('Foto gekoppeld', 'success');
|
||||
await this.open(this.selected.id);
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async loadNasFiles() {
|
||||
try {
|
||||
const r = await Cockpit.api('/documents/share-files?limit=200');
|
||||
this.nasFiles = r.items || r.files || [];
|
||||
this.filterNasFiles();
|
||||
} catch (e) { this.nasFiles = []; this.nasFilesFiltered = []; }
|
||||
},
|
||||
|
||||
filterNasFiles() {
|
||||
const q = (this.fileQ || '').toLowerCase();
|
||||
this.nasFilesFiltered = this.nasFiles.filter(f => {
|
||||
const name = (f.filename || f.name || '').toLowerCase();
|
||||
const path = (f.storage_path || f.path || '').toLowerCase();
|
||||
return !q || name.includes(q) || path.includes(q);
|
||||
});
|
||||
},
|
||||
|
||||
async linkNasFile(f) {
|
||||
if (!this.selected) return;
|
||||
const path = f.storage_path || f.path;
|
||||
const title = f.filename || f.name || path;
|
||||
await fetch('/api/projects/' + this.selected.id + '/link-nas-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, file_path: path, asset_type: 'document' }),
|
||||
});
|
||||
Cockpit.toast('Bestand gekoppeld', 'success');
|
||||
await this.open(this.selected.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,30 +1,240 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="suppliersCrud()">
|
||||
<header class="page-header"><h1>Suppliers</h1><p class="subtitle page-tip">Vendor registry with ratings and lead times.</p></header>
|
||||
<button class="btn btn-primary" @click="openNew()">Add supplier</button>
|
||||
<table class="data-table"><thead><tr><th>Name</th><th>Country</th><th>Rating</th><th></th></tr></thead>
|
||||
<tbody>{% for s in suppliers %}<tr class="clickable-row" @click="edit({{ s.id }}, '{{ s.name|e }}', '{{ (s.country or '')|e }}', {{ s.rating or 0 }})">
|
||||
<td>{{ s.name }}</td><td>{{ s.country }}</td><td>{{ s.rating }}</td>
|
||||
<td><button class="btn btn-sm btn-reject" @click.stop="remove({{ s.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
|
||||
<div class="modal" :class="{ open: show }" @click.self="show=false"><div class="modal-card">
|
||||
<input class="form-input" x-model="form.name" /><input class="form-input" x-model="form.country" /><input type="number" step="0.1" class="form-input" x-model="form.rating" />
|
||||
<button class="btn btn-primary" @click="save()">Save</button></div></div>
|
||||
<div class="clients-shell" x-data="suppliersHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 x-text="$t('suppliers.title')">Leveranciers</h1>
|
||||
<p class="subtitle page-tip" x-text="$t('suppliers.subtitle')"></p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
|
||||
<a class="btn btn-sm" href="/products" x-text="$t('nav.products')"></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="clients-kpi-row">
|
||||
<div class="clients-kpi"><span x-text="$t('suppliers.total')"></span><strong x-text="stats.total||0"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('suppliers.avg_rating')"></span><strong x-text="(stats.avg_rating||0)+'/5'"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('suppliers.countries')"></span><strong x-text="stats.countries||0"></strong></div>
|
||||
<div class="clients-kpi"><span x-text="$t('suppliers.avg_lead')"></span><strong x-text="(stats.avg_lead||0)+'d'"></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="clients-tabs">
|
||||
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('suppliers.tab_grid')"></button>
|
||||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('suppliers.tab_list')"></button>
|
||||
<div class="clients-tab-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('suppliers.new')"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='grid'" x-cloak>
|
||||
<div class="agents-neo-grid">
|
||||
<template x-for="s in filtered" :key="s.id">
|
||||
<article class="agent-soul-card" @click="openDetail(s)">
|
||||
<strong x-text="s.name"></strong>
|
||||
<div class="clients-card-meta" x-text="(s.country||'') + (s.category ? ' · '+categoryLabel(s.category) : '')"></div>
|
||||
<div class="clients-card-tags">
|
||||
<span class="clients-tag mrr" x-show="s.rating" x-text="'★ '+(s.rating||0)"></span>
|
||||
<span class="clients-tag" x-show="s.lead_time_days" x-text="s.lead_time_days+'d'"></span>
|
||||
</div>
|
||||
<div class="clients-card-meta" x-show="s.contact" x-text="s.contact"></div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<p class="clients-empty" x-show="!filtered.length" x-text="$t('suppliers.no_suppliers')"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="tab==='list'" x-cloak>
|
||||
<div class="clients-toolbar">
|
||||
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
|
||||
<select x-model="filterCountry" @change="applyFilter()">
|
||||
<option value="" x-text="$t('suppliers.filter_country') + ' — ' + $t('common.all')"></option>
|
||||
<template x-for="c in countryOptions" :key="c"><option :value="c" x-text="c"></option></template>
|
||||
</select>
|
||||
<select x-model="filterCategory" @change="applyFilter()">
|
||||
<option value="" x-text="$t('suppliers.filter_category') + ' — ' + $t('common.all')"></option>
|
||||
<template x-for="cat in categories" :key="cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
|
||||
</select>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th x-text="$t('suppliers.name')"></th><th x-text="$t('suppliers.country')"></th>
|
||||
<th x-text="$t('suppliers.category')"></th><th x-text="$t('suppliers.rating')"></th>
|
||||
<th x-text="$t('suppliers.lead_time')"></th><th x-text="$t('suppliers.moq')"></th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<template x-for="s in filtered" :key="'l'+s.id">
|
||||
<tr class="clickable-row" @click="openDetail(s)">
|
||||
<td><strong x-text="s.name"></strong></td>
|
||||
<td x-text="s.country||'—'"></td>
|
||||
<td x-text="categoryLabel(s.category)"></td>
|
||||
<td x-text="s.rating!=null?s.rating:'—'"></td>
|
||||
<td x-text="s.lead_time_days!=null?s.lead_time_days+'d':'—'"></td>
|
||||
<td x-text="s.moq||'—'"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm" @click.stop="editSupplier(s)" x-text="$t('common.edit')"></button>
|
||||
<button class="btn btn-sm btn-reject" @click.stop="remove(s.id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<h3 x-text="form.id ? $t('common.edit') : $t('suppliers.new')"></h3>
|
||||
<div class="clients-form-grid">
|
||||
<label class="full"><span class="filter-label" x-text="$t('suppliers.name')"></span>
|
||||
<input class="form-input" x-model="form.name" /></label>
|
||||
<label><span class="filter-label" x-text="$t('suppliers.country')"></span>
|
||||
<input class="form-input" x-model="form.country" list="country-list" /></label>
|
||||
<datalist id="country-list"><option>NL</option><option>BE</option><option>DE</option><option>TR</option><option>MA</option></datalist>
|
||||
<label><span class="filter-label" x-text="$t('suppliers.category')"></span>
|
||||
<select class="form-input" x-model="form.category">
|
||||
<option value="" x-text="$t('common.none')"></option>
|
||||
<template x-for="cat in categories" :key="'fc'+cat.id"><option :value="cat.id" x-text="cat.label"></option></template>
|
||||
</select></label>
|
||||
<label><span class="filter-label" x-text="$t('suppliers.rating')"></span>
|
||||
<input type="number" class="form-input" x-model="form.rating" min="0" max="5" step="0.1" /></label>
|
||||
<label><span class="filter-label" x-text="$t('suppliers.moq')"></span>
|
||||
<input type="number" class="form-input" x-model="form.moq" /></label>
|
||||
<label><span class="filter-label" x-text="$t('suppliers.lead_time')"></span>
|
||||
<input type="number" class="form-input" x-model="form.lead_time_days" /></label>
|
||||
<label class="full"><span class="filter-label" x-text="$t('suppliers.contact')"></span>
|
||||
<textarea class="form-input" rows="3" x-model="form.contact" placeholder="naam@bedrijf.nl · +31…"></textarea></label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
|
||||
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
|
||||
<button class="btn btn-reject" x-show="form.id" @click="remove(form.id); modal=false" x-text="$t('common.delete')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="clients-drawer" :class="{ open: drawer }">
|
||||
<div class="clients-drawer-head">
|
||||
<div><h2 x-text="detail?.supplier?.name||''"></h2>
|
||||
<p class="muted" x-text="(detail?.supplier?.country||'') + ' · ' + categoryLabel(detail?.supplier?.category)"></p></div>
|
||||
<button class="drawer-close" @click="drawer=false">×</button>
|
||||
</div>
|
||||
<template x-if="detail?.supplier">
|
||||
<div class="clients-card-meta">
|
||||
<div x-show="detail.supplier.rating"><strong x-text="$t('suppliers.rating')"></strong>: <span x-text="detail.supplier.rating+'/5'"></span></div>
|
||||
<div x-show="detail.supplier.moq"><strong x-text="$t('suppliers.moq')"></strong>: <span x-text="detail.supplier.moq"></span></div>
|
||||
<div x-show="detail.supplier.lead_time_days"><strong x-text="$t('suppliers.lead_time')"></strong>: <span x-text="detail.supplier.lead_time_days+' dagen'"></span></div>
|
||||
<div x-show="detail.supplier.contact" style="white-space:pre-wrap;margin-top:0.5rem" x-text="detail.supplier.contact"></div>
|
||||
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editSupplier(detail.supplier)" x-text="$t('common.edit')"></button>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function suppliersCrud() {
|
||||
return { show: false, id: null, form: { name: '', country: '', rating: 4, category: '', moq: null, lead_time_days: null, contact: '' },
|
||||
openNew() { this.id=null; this.form={ name:'', country:'', rating:4, category:'', moq:null, lead_time_days:null, contact:'' }; this.show=true; },
|
||||
edit(id,name,country,rating) { this.id=id; this.form={name,country,rating,category:'',moq:null,lead_time_days:null,contact:''}; this.show=true; },
|
||||
async save() {
|
||||
const body = { ...this.form, rating: parseFloat(this.form.rating) };
|
||||
if (this.id) await Cockpit.api('/suppliers/' + this.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
|
||||
location.reload();
|
||||
function suppliersHub() {
|
||||
return {
|
||||
tab: 'grid', busy: false, modal: false, drawer: false,
|
||||
stats: {}, suppliers: [], filtered: [], detail: null,
|
||||
filterQ: '', filterCountry: '', filterCategory: '',
|
||||
form: {}, categories: [], countryOptions: [],
|
||||
|
||||
buildCategories() {
|
||||
this.categories = [
|
||||
{ id: 'Meat', label: this.$t('suppliers.cat_meat') },
|
||||
{ id: 'Dairy', label: this.$t('suppliers.cat_dairy') },
|
||||
{ id: 'Spices', label: this.$t('suppliers.cat_spices') },
|
||||
{ id: 'Packaging', label: this.$t('suppliers.cat_packaging') },
|
||||
{ id: 'Logistics', label: this.$t('suppliers.cat_logistics') },
|
||||
{ id: 'Ingredients', label: this.$t('suppliers.cat_ingredients') },
|
||||
{ id: 'Other', label: this.$t('suppliers.cat_other') },
|
||||
];
|
||||
},
|
||||
categoryLabel(id) {
|
||||
if (!id) return '—';
|
||||
return (this.categories.find(c => c.id === id) || {}).label || id;
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.buildCategories();
|
||||
document.addEventListener('foodlinkk:locale', () => this.buildCategories());
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
try {
|
||||
const [statsR, supR] = await Promise.all([
|
||||
Cockpit.api('/suppliers/stats'),
|
||||
Cockpit.api('/suppliers'),
|
||||
]);
|
||||
this.stats = statsR.stats || {};
|
||||
this.suppliers = supR.items || [];
|
||||
const countries = new Set();
|
||||
this.suppliers.forEach(s => { if (s.country) countries.add(s.country); });
|
||||
this.countryOptions = Array.from(countries).sort();
|
||||
this.applyFilter();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
applyFilter() {
|
||||
const q = (this.filterQ || '').toLowerCase();
|
||||
this.filtered = this.suppliers.filter(s => {
|
||||
if (this.filterCountry && s.country !== this.filterCountry) return false;
|
||||
if (this.filterCategory && s.category !== this.filterCategory) return false;
|
||||
if (!q) return true;
|
||||
return [s.name, s.country, s.category, s.contact].some(
|
||||
f => f && String(f).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
openModal() {
|
||||
this.form = { id: null, name: '', country: 'NL', category: '', rating: 4, moq: '', lead_time_days: '', contact: '' };
|
||||
this.modal = true;
|
||||
},
|
||||
editSupplier(s) {
|
||||
this.form = {
|
||||
id: s.id, name: s.name || '', country: s.country || '', category: s.category || '',
|
||||
rating: s.rating ?? 4, moq: s.moq ?? '', lead_time_days: s.lead_time_days ?? '', contact: s.contact || '',
|
||||
};
|
||||
this.modal = true; this.drawer = false;
|
||||
},
|
||||
|
||||
async save() {
|
||||
const body = {
|
||||
name: this.form.name.trim(),
|
||||
country: this.form.country || null,
|
||||
category: this.form.category || null,
|
||||
rating: this.form.rating !== '' ? parseFloat(this.form.rating) : null,
|
||||
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
|
||||
lead_time_days: this.form.lead_time_days !== '' ? parseInt(this.form.lead_time_days, 10) : null,
|
||||
contact: this.form.contact || null,
|
||||
};
|
||||
try {
|
||||
if (this.form.id) await Cockpit.api('/suppliers/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
else await Cockpit.api('/suppliers', { method: 'POST', body: JSON.stringify(body) });
|
||||
Cockpit.toast(window.I18n.t('common.saved'), 'success');
|
||||
this.modal = false;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
if (!Cockpit.confirmDelete()) return;
|
||||
await Cockpit.api('/suppliers/' + id, { method: 'DELETE' });
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async openDetail(s) {
|
||||
const d = await Cockpit.api('/suppliers/' + s.id + '/detail');
|
||||
this.detail = d;
|
||||
this.drawer = true;
|
||||
},
|
||||
async remove(id) { if (!Cockpit.confirmDelete()) return; await Cockpit.api('/suppliers/' + id, { method: 'DELETE' }); location.reload(); }
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,6 +48,43 @@ curl -sf -o /dev/null -w "marketing:%{http_code}\n" http://127.0.0.1:8600/market
|
||||
curl -sf -o /dev/null -w "ops:%{http_code}\n" http://127.0.0.1:8600/ops
|
||||
curl -sf -o /dev/null -w "packaging:%{http_code}\n" http://127.0.0.1:8600/packaging
|
||||
curl -sf -o /dev/null -w "agents:%{http_code}\n" http://127.0.0.1:8600/agents
|
||||
|
||||
echo "=== NAS client folders root ==="
|
||||
mkdir -p /home/aissa/nas-clients
|
||||
|
||||
echo "=== Gitea SysOps user ==="
|
||||
chmod +x scripts/setup_gitea_sysops.sh 2>/dev/null || true
|
||||
bash scripts/setup_gitea_sysops.sh || true
|
||||
|
||||
echo "=== SysOps + Herman cron ==="
|
||||
(crontab -l 2>/dev/null || true | grep -v sysops_daily.py | grep -v herman_daily_digest.py | grep -v herman_platform_update.py; \
|
||||
echo "0 7 * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/sysops_daily.py >> /tmp/sysops-daily.log 2>&1"; \
|
||||
echo "30 7 * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_daily_digest.py >> /tmp/herman-daily.log 2>&1") | sort -u | crontab -
|
||||
echo "Cron: SysOps 07:00, Herman briefing 07:30"
|
||||
|
||||
echo "=== Run SysOps daily now (test) ==="
|
||||
cd "$REMOTE_DIR" && python3 scripts/sysops_daily.py || true
|
||||
|
||||
echo "=== Schedule Herman platform update over 2 uur ==="
|
||||
if command -v at >/dev/null 2>&1; then
|
||||
echo "cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_platform_update.py >> /tmp/herman-update.log 2>&1" | at now + 2 hours 2>&1 || true
|
||||
echo "Gepland via 'at now + 2 hours'"
|
||||
else
|
||||
UPD_H=$(date -d '+2 hours' +%H 2>/dev/null || date -v+2H +%H 2>/dev/null || echo "09")
|
||||
UPD_M=$(date -d '+2 hours' +%M 2>/dev/null || date -v+2H +%M 2>/dev/null || echo "00")
|
||||
(crontab -l 2>/dev/null || true; echo "\$UPD_M \$UPD_H * * * cd $REMOTE_DIR && /usr/bin/python3 scripts/herman_platform_update.py >> /tmp/herman-update.log 2>&1 # once") | sort -u | crontab -
|
||||
echo "Gepland via cron om \$UPD_H:\$UPD_M"
|
||||
fi
|
||||
|
||||
echo "=== SysOps Gitea sync (volledige repo) ==="
|
||||
docker exec foodlinkk_tools_api python3 -c "
|
||||
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
|
||||
import json
|
||||
print(json.dumps(run_gitea_repo_sync('deploy-all'), indent=2))
|
||||
" || echo "Gitea sync warning — check tools-api logs"
|
||||
|
||||
echo "=== Herman briefing nu genereren ==="
|
||||
cd "$REMOTE_DIR" && python3 scripts/herman_daily_digest.py || true
|
||||
REMOTE
|
||||
|
||||
echo "=== Deploy voltooid ==="
|
||||
|
||||
@@ -16,6 +16,14 @@ services:
|
||||
DB_USER: aissa
|
||||
DB_PASSWORD: Foodlinkk#2026
|
||||
DB_NAME: foodlinkk
|
||||
BACKUP_ROOT: /data/backup-root
|
||||
GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
|
||||
GITEA_SYSOPS_USER: sysops
|
||||
GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
|
||||
GITEA_SYSOPS_NAME: sysops
|
||||
GITEA_BACKUP_BRANCH: main
|
||||
PROXMOX_HOST: 10.4.7.14
|
||||
VM106_IP: 10.4.7.18
|
||||
OLLAMA_URL: http://10.4.7.19:11434
|
||||
OLLAMA_MODEL: qwen3:8b
|
||||
CHROMA_HOST: 10.4.7.19
|
||||
@@ -31,6 +39,8 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
ports:
|
||||
- "8700:8700"
|
||||
volumes:
|
||||
- /home/aissa/foodlinkk-command-center:/data/backup-root:ro
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
restart: unless-stopped
|
||||
@@ -53,8 +63,11 @@ services:
|
||||
BROWSER_AGENT_URL: http://browser-agent:7790
|
||||
COMFYUI_URL: http://10.4.7.18:8188
|
||||
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
|
||||
NAS_CLIENTS_ROOT: /data/nas-clients
|
||||
ports:
|
||||
- "8600:8600"
|
||||
volumes:
|
||||
- /home/aissa/nas-clients:/data/nas-clients
|
||||
depends_on:
|
||||
- tools-api
|
||||
extra_hosts:
|
||||
|
||||
@@ -22,3 +22,10 @@ Module rechten voor Herman: `/settings?tab=permissions` — tabel `herman_permis
|
||||
## Agent events
|
||||
|
||||
Marketing publish, reclamefolder sync en ops refreshes loggen naar `agent_events` voor traceerbaarheid.
|
||||
|
||||
## SysOps
|
||||
|
||||
- Dagelijks 07:00: approval requests (maintenance scan + backup)
|
||||
- **Gitea:** volledige repo-sync bij deploy en na goedgekeurde backup
|
||||
- Verslag: tabel `sysops_activity` + `/ops` UI + Herman briefing sectie "SysOps IT"
|
||||
- Gitea user: `sysops` — zie `scripts/setup_gitea_sysops.sh`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Foodlinkk Command Center — Context (voor Cursor / nieuwe machine)
|
||||
|
||||
> **Volledige documentatie:** [PLATFORM-AZ.md](./PLATFORM-AZ.md)
|
||||
> **Gitea repo:** http://10.4.7.18:3001/aissa/foodlinkk-command-center
|
||||
|
||||
## Snel overzicht
|
||||
|
||||
| Item | Waarde |
|
||||
|------|--------|
|
||||
| VM106 | `10.4.7.18` |
|
||||
| Cockpit | http://10.4.7.18:8600 |
|
||||
| Tools API | http://10.4.7.18:8700 |
|
||||
| Gitea | http://10.4.7.18:3001 |
|
||||
| Code op server | `/home/aissa/foodlinkk-command-center` |
|
||||
| NAS klanten | `/home/aissa/nas-clients` |
|
||||
| Deploy | `bash deploy-all.sh` (rsync + migraties + docker + **Gitea push**) |
|
||||
| SSH | `aissa@10.4.7.18` |
|
||||
|
||||
## Stack
|
||||
|
||||
- **Cockpit** — FastAPI + Jinja2 + Alpine.js (UI, proxy naar Tools API)
|
||||
- **Tools API** — Connectors: retail, packaging, ops, email, CRM
|
||||
- **PostgreSQL** — `foodlinkk_db`, migraties `001`–`023`
|
||||
- **16 agents + Herman** — souls, events, mesh, goedkeuringen
|
||||
- **VM105** — Ollama `qwen3:8b`, Herman orchestrator `:8090`, ChromaDB
|
||||
|
||||
## SysOps & Gitea
|
||||
|
||||
- User **sysops** pusht naar Gitea bij elke **deploy-all**
|
||||
- Goedgekeurde backup → zelfde volledige repo-sync
|
||||
- Activiteit in `sysops_activity` + **IT Ops** tab + **Herman briefing**
|
||||
- Cron 07:00 SysOps (approval scan/backup), 07:30 Herman briefing
|
||||
|
||||
## Belangrijkste features (2026)
|
||||
|
||||
- Agents: 3D avatars, klikbare events, SysOps met petje
|
||||
- Packaging Studio + Herman `maak verpakking: ...`
|
||||
- Projecten + NAS per klant (`packaging/`, `photos/`, …)
|
||||
- Clients CRM + i18n NL/EN
|
||||
- Herman dashboard: briefing, approvals, viz
|
||||
|
||||
## Cursor op andere laptop
|
||||
|
||||
```bash
|
||||
git clone http://10.4.7.18:3001/aissa/foodlinkk-command-center.git
|
||||
cd foodlinkk-command-center
|
||||
cursor .
|
||||
```
|
||||
|
||||
In chat: `@docs/CONTEXT.md` `@docs/PLATFORM-AZ.md`
|
||||
|
||||
## Taal & conventies
|
||||
|
||||
- UI default Nederlands (`cockpit/static/i18n/nl.json`)
|
||||
- Geen commits zonder expliciete vraag
|
||||
- Deploy altijd via `deploy-all.sh`
|
||||
@@ -21,6 +21,8 @@ Het script:
|
||||
2. Voert alle SQL migraties uit (`migrations/*.sql`)
|
||||
3. Bouwt en herstart containers (`tools-api`, `cockpit`, `email-agent`, `browser-agent`)
|
||||
4. Voert health checks uit
|
||||
5. **SysOps pusht volledige repo naar Gitea** (`main`, user `sysops`)
|
||||
6. Genereert Herman briefing (activiteitenlog incl. SysOps)
|
||||
|
||||
## Omgevingsvariabelen
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# Foodlinkk Command Center — Platform A tot Z
|
||||
|
||||
*Laatst bijgewerkt: juni 2026 · VM106 `10.4.7.18`*
|
||||
|
||||
---
|
||||
|
||||
## A. Architectuur
|
||||
|
||||
```
|
||||
Proxmox 10.4.7.14 (Dell R340)
|
||||
├── VM105 10.4.7.19 — Ollama, ChromaDB, Herman orchestrator :8090
|
||||
└── VM106 10.4.7.18 — Command Center (Docker)
|
||||
├── cockpit :8600
|
||||
├── tools-api :8700
|
||||
├── gitea :3001
|
||||
├── email-agent :8801
|
||||
├── browser-agent :7790
|
||||
├── prometheus :9090
|
||||
├── grafana :3002
|
||||
└── PostgreSQL foodlinkk_db (host)
|
||||
```
|
||||
|
||||
**Dataflow:** Browser → Cockpit → (proxy) Tools API → PostgreSQL / externe APIs
|
||||
|
||||
---
|
||||
|
||||
## B. Bestandslocaties
|
||||
|
||||
| Pad | Inhoud |
|
||||
|-----|--------|
|
||||
| `/home/aissa/foodlinkk-command-center` | Volledige monorepo (live) |
|
||||
| `/home/aissa/nas-clients` | Klant-NAS (gemount in cockpit) |
|
||||
| `/tmp/sysops-daily.log` | SysOps cron log |
|
||||
| `/tmp/herman-daily.log` | Herman briefing cron |
|
||||
|
||||
---
|
||||
|
||||
## C. Cockpit — alle pagina's
|
||||
|
||||
| Route | Module |
|
||||
|-------|--------|
|
||||
| `/` | Herman CEO dashboard |
|
||||
| `/herman` | Herman chat (delegatie, packaging, foto's) |
|
||||
| `/agents` | Souls, mesh, goedkeuringen, events |
|
||||
| `/retail` | Retail 360 |
|
||||
| `/marketing` | Social, reclame, RSS |
|
||||
| `/clients` | CRM + supermarkten |
|
||||
| `/products`, `/suppliers` | Assortiment & inkoop |
|
||||
| `/projects` | Food-business projecten + NAS |
|
||||
| `/packaging` | Packaging Studio |
|
||||
| `/ops` | IT topology, SysOps verslag, Gitea |
|
||||
| `/documents` | Documenten hub |
|
||||
| `/settings` | Email, social, Herman permissions |
|
||||
| `/studio` | ComfyUI |
|
||||
| `/beurs` | Marktdata |
|
||||
|
||||
---
|
||||
|
||||
## D. Database (PostgreSQL)
|
||||
|
||||
Migraties in `migrations/`:
|
||||
|
||||
| # | Onderwerp |
|
||||
|---|-----------|
|
||||
| 001 | agent_events, clients |
|
||||
| 005–008 | email, browser, pgvector |
|
||||
| 011–017 | retail 360, CRM |
|
||||
| 016 | packaging agent, sysops soul |
|
||||
| 018 | agent_action_requests (goedkeuringen) |
|
||||
| 019 | cockpit_projects, project_assets |
|
||||
| 021 | locale_preferences (i18n) |
|
||||
| 022 | NAS-structuur per klant |
|
||||
| 023 | sysops_activity log |
|
||||
|
||||
**Kern-tabellen:** `agent_souls`, `agent_events`, `agent_action_requests`, `cockpit_projects`, `project_assets`, `packaging_outputs`, `config_backups`, `sysops_activity`, `clients`, `supermarkets`, `deals`
|
||||
|
||||
---
|
||||
|
||||
## E. Agents (16 + Herman)
|
||||
|
||||
| Key | Rol |
|
||||
|-----|-----|
|
||||
| herman | Co-CEO, briefing, orchestratie |
|
||||
| sysops | IT, Gitea sync, maintenance |
|
||||
| packaging | Verpakkingsontwerp |
|
||||
| marketing | Social, campagnes |
|
||||
| retail | Supermarkten 360 |
|
||||
| research | Marktonderzoek |
|
||||
| design | ComfyUI |
|
||||
| … | bizdev, finance, sourcing, product, halal, knowledge, browser, hermes, email |
|
||||
|
||||
**Events:** `agent_events` — zichtbaar op `/agents`, klikbaar per agent.
|
||||
|
||||
**Goedkeuringen:** SysOps vraagt vóór maintenance scan en optionele backup.
|
||||
|
||||
---
|
||||
|
||||
## F. SysOps — Gitea & verslag
|
||||
|
||||
### Automatische Gitea push
|
||||
|
||||
Bij **elke deploy** (`deploy-all.sh`):
|
||||
1. Rsync code naar VM106
|
||||
2. Docker rebuild
|
||||
3. **SysOps pusht volledige repo** naar Gitea `main` (user `sysops`)
|
||||
|
||||
API: `POST /ops/gitea/sync` body `{"reason":"manual"}`
|
||||
|
||||
### Goedgekeurde backup
|
||||
|
||||
Na CEO ✓ op approval `config_backup` → zelfde volledige repo-sync.
|
||||
|
||||
### Activiteitenlog
|
||||
|
||||
Tabel `sysops_activity`:
|
||||
- `gitea_sync` — deploy, backup
|
||||
- `maintenance_scan` — na scan
|
||||
|
||||
Zichtbaar op **IT Ops** (`/ops`) → paneel "SysOps verslag".
|
||||
|
||||
### Herman briefing
|
||||
|
||||
`collect_briefing_data()` leest:
|
||||
- `sysops_activity_24h`
|
||||
- `sysops_events_24h`
|
||||
- Sectie **"SysOps IT — laatste 24 uur"** in dagrapport
|
||||
|
||||
Cron: 07:00 SysOps requests, 07:30 Herman briefing.
|
||||
|
||||
---
|
||||
|
||||
## G. Gitea
|
||||
|
||||
- URL: http://10.4.7.18:3001
|
||||
- Repo: `aissa/foodlinkk-command-center`
|
||||
- SysOps user: `sysops` (write, setup via `scripts/setup_gitea_sysops.sh`)
|
||||
- Documentatie staat **in de repo** onder `docs/`
|
||||
|
||||
**Clone op andere machine:**
|
||||
```bash
|
||||
git clone http://10.4.7.18:3001/aissa/foodlinkk-command-center.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## H. Herman
|
||||
|
||||
- Chat: `/api/herman/chat`
|
||||
- Briefing: `POST /api/herman/briefing`
|
||||
- Orchestrator: `HERMAN_ORCHESTRATOR_URL` → VM105 `:8090`
|
||||
- Packaging opdracht: `maak verpakking: pouch 100×140 voor Product X project #5`
|
||||
|
||||
---
|
||||
|
||||
## I. Packaging
|
||||
|
||||
- UI: `/packaging` — 6 types, brand, tekst, NAS-export
|
||||
- API: Tools API `/packaging/*`
|
||||
- NAS: `{klant}/projects/{id}/packaging/*.svg|png|pdf|json`
|
||||
- Herman: `packaging_agent.py` → generate + events
|
||||
|
||||
---
|
||||
|
||||
## J. Projecten & NAS
|
||||
|
||||
Per klant: `/home/aissa/nas-clients/{slug}/`
|
||||
Per project: `projects/{id-naam}/photos|documents|packaging|exports|briefs/`
|
||||
|
||||
API: `/api/projects` — create met `ensure_nas: true`
|
||||
|
||||
---
|
||||
|
||||
## K. Deploy
|
||||
|
||||
```bash
|
||||
# Vanaf dev machine met /tmp/foodlinkk-deploy of lokale clone:
|
||||
bash deploy-all.sh
|
||||
```
|
||||
|
||||
Stappen: rsync → migraties → docker build → health checks → Gitea sync → Herman briefing.
|
||||
|
||||
Omgevingsvariabelen: `.env.example` (niet committen).
|
||||
|
||||
---
|
||||
|
||||
## L. i18n
|
||||
|
||||
- `cockpit/static/i18n/nl.json`, `en.json`
|
||||
- Selector in topbar
|
||||
- Uitgebreid: nav, clients, products, suppliers
|
||||
|
||||
---
|
||||
|
||||
## M. Monitoring
|
||||
|
||||
- Prometheus `:9090`, Grafana `:3002`
|
||||
- IT Ops topology: Proxmox + services live
|
||||
- Infra snapshots: `infra_snapshots`
|
||||
|
||||
---
|
||||
|
||||
## N. Email
|
||||
|
||||
- Settings → SMTP accounts
|
||||
- Tools API `POST /emails/send`
|
||||
- Packaging kan designs emailen met downloadlinks
|
||||
|
||||
---
|
||||
|
||||
## O. Ontwikkeling
|
||||
|
||||
| Component | Tech |
|
||||
|-----------|------|
|
||||
| Cockpit | Python 3.11, FastAPI, Jinja2, Alpine.js |
|
||||
| Tools API | FastAPI, psycopg2 |
|
||||
| Viz | Chart.js, viz-engine.js |
|
||||
| Packaging | svgwrite, python-barcode, Pillow, reportlab |
|
||||
|
||||
**Tests:** health curls in `deploy-all.sh` en `docs/DEPLOY.md`
|
||||
|
||||
---
|
||||
|
||||
## P. Veiligheid
|
||||
|
||||
- SysOps: geen destructieve acties zonder goedkeuring
|
||||
- `.env` in `.gitignore`
|
||||
- Gitea: geen public registration
|
||||
- Wachtwoorden in docker-compose op server (niet in git idealiter — gebruik `.env`)
|
||||
|
||||
---
|
||||
|
||||
## Q. Troubleshooting
|
||||
|
||||
| Probleem | Actie |
|
||||
|----------|-------|
|
||||
| Cockpit 502 | `docker compose logs cockpit` |
|
||||
| Gitea push faalt | `scripts/setup_gitea_sysops.sh`, check collaborator |
|
||||
| Briefing leeg | `python3 scripts/herman_daily_digest.py` |
|
||||
| NAS map ontbreekt | Project → ensure-nas of opnieuw aanmaken |
|
||||
|
||||
---
|
||||
|
||||
## R. Referenties
|
||||
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
- [APPS.md](./APPS.md)
|
||||
- [API.md](./API.md)
|
||||
- [AGENTS.md](./AGENTS.md)
|
||||
- [DEPLOY.md](./DEPLOY.md)
|
||||
- [CONTEXT.md](./CONTEXT.md) — korte Cursor-context
|
||||
@@ -0,0 +1,15 @@
|
||||
-- RetailDetail Food category feed + trending focus
|
||||
|
||||
UPDATE rss_feeds SET
|
||||
name = 'RetailDetail Food',
|
||||
url = 'https://www.retaildetail.nl/category/food/feed/',
|
||||
category = 'food',
|
||||
is_active = TRUE
|
||||
WHERE name IN ('RetailDetail', 'RetailDetail Food');
|
||||
|
||||
INSERT INTO rss_feeds (name, url, category, is_active) VALUES
|
||||
('RetailDetail Food', 'https://www.retaildetail.nl/category/food/feed/', 'food', TRUE)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
category = EXCLUDED.category,
|
||||
is_active = TRUE;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Agent action approval workflow (query gate before execution)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_action_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
agent_key VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(64) NOT NULL DEFAULT 'query',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
query_payload JSONB DEFAULT '{}'::jsonb,
|
||||
status VARCHAR(32) DEFAULT 'pending',
|
||||
approved_by VARCHAR(64),
|
||||
rejection_reason TEXT,
|
||||
result JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
executed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_action_requests_status ON agent_action_requests(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_action_requests_agent ON agent_action_requests(agent_key, created_at DESC);
|
||||
|
||||
UPDATE agent_souls SET
|
||||
role_title = 'Co-CEO · Takenverdeler',
|
||||
soul_md = '# Herman — Soul
|
||||
|
||||
Je bent Herman, AI Co-CEO van Foodlinkk. Warm, direct, zakelijk.
|
||||
|
||||
## Karakter
|
||||
- Co-CEO naast Aissa (CEO) — delegeert taken naar gespecialiseerde agents
|
||||
- Alle agent-output wordt geaggregeerd; jij rapporteert naar CEO en CTO
|
||||
- Geen autonome acties zonder goedgekeurde approval requests
|
||||
|
||||
## Werkzaamheden
|
||||
- CEO briefing · orchestratie · mesh hub · goedkeuringsflow bewaken
|
||||
- Taken verdelen over retail, marketing, ops, research, packaging
|
||||
|
||||
## Samenwerking
|
||||
- Agents → Herman → CEO / CTO
|
||||
- Externe queries: altijd via approval queue',
|
||||
responsibilities = 'Co-CEO · takenverdeler · briefing · orchestratie · mesh hub · approvals'
|
||||
WHERE agent_key = 'herman';
|
||||
|
||||
UPDATE agent_souls SET
|
||||
role_title = 'Markt Research · Intel',
|
||||
responsibilities = 'Web research · concurrenten · retail trends · markt intel (via approval)'
|
||||
WHERE agent_key = 'research';
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Unified projects, assets linking, UI preferences (layout + viz modes)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cockpit_projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(32) DEFAULT 'active',
|
||||
created_by VARCHAR(64) DEFAULT 'ceo',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_client ON cockpit_projects(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_status ON cockpit_projects(status, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_assets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES cockpit_projects(id) ON DELETE CASCADE,
|
||||
asset_type VARCHAR(64) NOT NULL,
|
||||
ref_id VARCHAR(128),
|
||||
title VARCHAR(255),
|
||||
file_path TEXT,
|
||||
payload JSONB DEFAULT '{}'::jsonb,
|
||||
created_by VARCHAR(64) DEFAULT 'ceo',
|
||||
source_agent VARCHAR(64),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_assets_project ON project_assets(project_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_assets_type ON project_assets(asset_type, ref_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packaging_outputs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
project_id INTEGER REFERENCES cockpit_projects(id) ON DELETE SET NULL,
|
||||
spec JSONB NOT NULL,
|
||||
svg TEXT NOT NULL,
|
||||
created_by VARCHAR(64) DEFAULT 'packaging',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_ui_preferences (
|
||||
user_key VARCHAR(64) PRIMARY KEY DEFAULT 'ceo',
|
||||
dashboard_layout JSONB DEFAULT '[]'::jsonb,
|
||||
viz_modes JSONB DEFAULT '{}'::jsonb,
|
||||
global_viz_mode VARCHAR(32) DEFAULT 'neo-bars',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode) VALUES
|
||||
('ceo', '["kpis","executive","briefing","retail","analytics","approvals","feed"]'::jsonb, 'neo-bars')
|
||||
ON CONFLICT (user_key) DO NOTHING;
|
||||
|
||||
ALTER TABLE photo_imports ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES cockpit_projects(id) ON DELETE SET NULL;
|
||||
ALTER TABLE photo_imports ADD COLUMN IF NOT EXISTS created_by VARCHAR(64) DEFAULT 'ceo';
|
||||
@@ -0,0 +1,58 @@
|
||||
-- SysOps daily config backup, maintenance notes, permissions
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_backups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
approval_request_id INTEGER REFERENCES agent_action_requests(id) ON DELETE SET NULL,
|
||||
commit_ref VARCHAR(128),
|
||||
files_count INTEGER DEFAULT 0,
|
||||
status VARCHAR(32) DEFAULT 'pending',
|
||||
message TEXT,
|
||||
payload JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_backups_created ON config_backups(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ops_maintenance_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
severity VARCHAR(16) DEFAULT 'info',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
source VARCHAR(64) DEFAULT 'sysops',
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ops_maintenance_created ON ops_maintenance_notes(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ops_maintenance_open ON ops_maintenance_notes(resolved, created_at DESC);
|
||||
|
||||
UPDATE agent_souls SET
|
||||
soul_md = '# SysOps — Soul
|
||||
|
||||
Je bewaakt en onderhoudt de Foodlinkk IT-omgeving op **Dell PowerEdge R340** met **Proxmox VE**.
|
||||
|
||||
## Karakter
|
||||
- Nuchter, alert, rapporteert feiten zonder paniek
|
||||
- Geen destructieve acties zonder CEO-goedkeuring via approval queue
|
||||
|
||||
## Werkzaamheden
|
||||
- Proxmox node + VM106 (Command Center) monitoren
|
||||
- Docker container health op 10.4.7.18
|
||||
- **Dagelijks config-backup naar Gitea** (na CEO approval)
|
||||
- Dagelijks maintenance-rapport in IT Ops tab
|
||||
- Infra topologie live bijhouden
|
||||
|
||||
## Rechten
|
||||
- `config_backup` — backup docker-compose, migraties, monitoring naar Gitea
|
||||
- `proxmox_read` — status uitlezen via Proxmox API/SSH
|
||||
- `maintenance_report` — updates/patches rapporteren
|
||||
|
||||
## Samenwerking
|
||||
- Herman: infra alerts en goedkeuringen
|
||||
- Research: geen infra-mutaties',
|
||||
responsibilities = 'Proxmox R340 · VM106 · Docker · dagelijkse Gitea backup · maintenance',
|
||||
permissions = '{"config_backup": true, "proxmox_read": true, "gitea_push": true, "maintenance_report": true}'::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE agent_key = 'sysops';
|
||||
|
||||
INSERT INTO herman_permissions (module_key, module_label, description, granted) VALUES
|
||||
('ops', 'IT Ops', 'Infra topologie, backups, maintenance', true)
|
||||
ON CONFLICT (module_key) DO UPDATE SET granted = true, module_label = EXCLUDED.module_label;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- UI locale preference (nl | en)
|
||||
ALTER TABLE user_ui_preferences ADD COLUMN IF NOT EXISTS locale VARCHAR(8) DEFAULT 'nl';
|
||||
UPDATE user_ui_preferences SET locale = 'nl' WHERE locale IS NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Projects: type, NAS paths, client folder roots
|
||||
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS nas_folder TEXT;
|
||||
|
||||
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS project_type VARCHAR(64) DEFAULT 'general';
|
||||
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS nas_path TEXT;
|
||||
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS nas_client_root TEXT;
|
||||
ALTER TABLE cockpit_projects ADD COLUMN IF NOT EXISTS priority VARCHAR(16) DEFAULT 'normal';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_type ON cockpit_projects(project_type, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cockpit_projects_client ON cockpit_projects(client_id, updated_at DESC);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- SysOps activiteitenlog — verslagen voor IT Ops UI en Herman briefing
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sysops_activity (
|
||||
id SERIAL PRIMARY KEY,
|
||||
action_type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
commit_ref VARCHAR(128),
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
status VARCHAR(32) DEFAULT 'success',
|
||||
source VARCHAR(64) DEFAULT 'sysops',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sysops_activity_created ON sysops_activity(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sysops_activity_type ON sysops_activity(action_type, created_at DESC);
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Herman daily CEO briefing with full activity documentation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> int:
|
||||
url = "http://127.0.0.1:8600/api/herman/briefing"
|
||||
req = urllib.request.Request(url, data=b"{}", headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
print("Herman briefing generated:", (data.get("generated_at") or "")[:19])
|
||||
print("Stats pending approvals:", (data.get("stats") or {}).get("pending_approvals"))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print("Herman briefing failed:", exc, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scheduled platform update briefing for Herman — documents all recent platform work."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PLATFORM_UPDATE = """
|
||||
Platform update Foodlinkk Command Center:
|
||||
|
||||
1. Projecten — unified opslag voor foto's, packaging, agent-output met klantkoppeling
|
||||
2. Herman dashboard — widgets slepen, 5 viz-modi, agent goedkeuringen op dashboard
|
||||
3. IT Ops — Dell R340 topologie, SysOps dagelijkse backup + update-scan via approval
|
||||
4. Agent assets — marketing publish, research briefs, approvals → auto project koppeling
|
||||
5. Viz engine — Marketing, Agents, IT Ops pagina's met 5 visualisatiestijlen
|
||||
6. SysOps workflow — vraagt nu eerst goedkeuring voor update-scan vóór uitvoering
|
||||
7. Herman briefing — documenteert dagelijks alle agent-acties in activiteitenlog
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
print(f"=== Herman platform update briefing {now} ===")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8600/api/herman/briefing",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
briefing = json.loads(resp.read().decode("utf-8"))
|
||||
print("Briefing OK — generated_at:", (briefing.get("generated_at") or "")[:19])
|
||||
except Exception as exc:
|
||||
print("Briefing generation failed:", exc, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(PLATFORM_UPDATE)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Maak SysOps Gitea-gebruiker aan (login: sysops) en geef write op backup-repo.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-http://127.0.0.1:3001}"
|
||||
ADMIN_USER="${GITEA_ADMIN_USER:-aissa}"
|
||||
ADMIN_PASS="${GITEA_ADMIN_PASS:-Foodlinkk#2026}"
|
||||
SYSOPS_USER="${GITEA_SYSOPS_USER:-sysops}"
|
||||
SYSOPS_PASS="${GITEA_SYSOPS_PASS:-Foodlinkk#SysOps2026}"
|
||||
SYSOPS_EMAIL="${GITEA_SYSOPS_EMAIL:-sysops@foodlinkk.local}"
|
||||
REPO_OWNER="${GITEA_REPO_OWNER:-aissa}"
|
||||
REPO_NAME="${GITEA_REPO_NAME:-foodlinkk-command-center}"
|
||||
CONTAINER="${GITEA_CONTAINER:-foodlinkk_gitea}"
|
||||
|
||||
echo "=== Gitea SysOps setup ($SYSOPS_USER) ==="
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
|
||||
if ! docker exec "$CONTAINER" gitea admin user list 2>/dev/null | grep -q "^${SYSOPS_USER} "; then
|
||||
echo "Creating user $SYSOPS_USER..."
|
||||
docker exec "$CONTAINER" gitea admin user create \
|
||||
--username "$SYSOPS_USER" \
|
||||
--password "$SYSOPS_PASS" \
|
||||
--email "$SYSOPS_EMAIL" \
|
||||
--must-change-password=false 2>/dev/null || true
|
||||
else
|
||||
echo "User $SYSOPS_USER exists — updating password..."
|
||||
docker exec "$CONTAINER" gitea admin user change-password \
|
||||
--username "$SYSOPS_USER" \
|
||||
--password "$SYSOPS_PASS" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "Gitea container not running — skip user create (will retry on deploy)"
|
||||
fi
|
||||
|
||||
enc_pass() {
|
||||
python3 -c "import urllib.parse; print(urllib.parse.quote('''$1''', safe=''))"
|
||||
}
|
||||
|
||||
SYSOPS_PASS_ENC=$(enc_pass "$SYSOPS_PASS")
|
||||
AUTH=$(printf '%s:%s' "$ADMIN_USER" "$ADMIN_PASS" | base64 -w0 2>/dev/null || printf '%s:%s' "$ADMIN_USER" "$ADMIN_PASS" | base64)
|
||||
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X PUT "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/collaborators/${SYSOPS_USER}" \
|
||||
-H "Authorization: Basic ${AUTH}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"permission":"write"}' 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "204" ] || [ "$HTTP_CODE" = "201" ]; then
|
||||
echo "Collaborator $SYSOPS_USER → write on ${REPO_OWNER}/${REPO_NAME}"
|
||||
elif [ "$HTTP_CODE" = "422" ]; then
|
||||
echo "Collaborator already configured"
|
||||
else
|
||||
echo "Note: collaborator API returned HTTP $HTTP_CODE (repo may not exist yet)"
|
||||
fi
|
||||
|
||||
REMOTE="http://${SYSOPS_USER}:${SYSOPS_PASS_ENC}@gitea:3001/${REPO_OWNER}/${REPO_NAME}.git"
|
||||
echo "GITEA_BACKUP_REMOTE=$REMOTE"
|
||||
echo "=== SysOps kan pushen als $SYSOPS_USER ==="
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SysOps daily — approval requests for update scan + Gitea backup (no auto-scan)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COCKPIT_URL = "http://127.0.0.1:8600"
|
||||
|
||||
|
||||
def _post(url: str, payload: dict | None = None, timeout: int = 60) -> dict:
|
||||
data = json.dumps(payload or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _get(url: str, timeout: int = 30) -> dict:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _already_pending(action_type: str) -> bool:
|
||||
try:
|
||||
data = _get(f"{COCKPIT_URL}/api/agents/approvals?status=pending&limit=50")
|
||||
return any(
|
||||
i.get("agent_key") == "sysops" and i.get("action_type") == action_type
|
||||
for i in (data.get("items") or [])
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
results: dict = {"date": today, "steps": []}
|
||||
|
||||
if not _already_pending("maintenance_scan"):
|
||||
try:
|
||||
scan_req = _post(
|
||||
f"{COCKPIT_URL}/api/agents/requests",
|
||||
{
|
||||
"agent_key": "sysops",
|
||||
"action_type": "maintenance_scan",
|
||||
"title": f"Mag ik een update-scan uitvoeren op VM106? — {today}",
|
||||
"query_payload": {
|
||||
"scope": "vm106",
|
||||
"checks": ["apt upgrades", "disk usage", "docker health", "proxmox reachability"],
|
||||
"host": "10.4.7.18",
|
||||
},
|
||||
},
|
||||
)
|
||||
results["steps"].append({"maintenance_scan_request": scan_req})
|
||||
print("Maintenance scan approval request:", (scan_req.get("request") or {}).get("id"))
|
||||
except Exception as exc:
|
||||
results["steps"].append({"maintenance_scan_error": str(exc)})
|
||||
print("Maintenance scan request failed:", exc, file=sys.stderr)
|
||||
else:
|
||||
print("Maintenance scan request already pending — skipped")
|
||||
results["steps"].append({"maintenance_scan_request": "already_pending"})
|
||||
|
||||
if not _already_pending("config_backup"):
|
||||
try:
|
||||
backup_req = _post(
|
||||
f"{COCKPIT_URL}/api/agents/requests",
|
||||
{
|
||||
"agent_key": "sysops",
|
||||
"action_type": "config_backup",
|
||||
"title": f"Dagelijkse config backup naar Gitea — {today}",
|
||||
"query_payload": {
|
||||
"target": "gitea",
|
||||
"repo": "aissa/foodlinkk-command-center",
|
||||
"paths": ["docker-compose.yml", "migrations", "monitoring"],
|
||||
},
|
||||
},
|
||||
)
|
||||
results["steps"].append({"backup_request": backup_req})
|
||||
print("Backup approval request:", (backup_req.get("request") or {}).get("id"))
|
||||
except Exception as exc:
|
||||
results["steps"].append({"backup_error": str(exc)})
|
||||
print("Backup request failed:", exc, file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("Backup request already pending — skipped")
|
||||
results["steps"].append({"backup_request": "already_pending"})
|
||||
|
||||
print(json.dumps(results, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 git openssh-client sshpass ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Daily configuration backup to Gitea — executed after CEO approval."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
BACKUP_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
|
||||
GITEA_REMOTE = os.getenv(
|
||||
"GITEA_BACKUP_REMOTE",
|
||||
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
|
||||
)
|
||||
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
|
||||
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
|
||||
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
|
||||
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
|
||||
|
||||
BACKUP_PATHS = [
|
||||
"docker-compose.yml",
|
||||
".env.example",
|
||||
"migrations",
|
||||
"monitoring",
|
||||
"cockpit/requirements.txt",
|
||||
"tools-api/requirements.txt",
|
||||
"deploy-all.sh",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "timeout", "cmd": cmd}
|
||||
except FileNotFoundError as exc:
|
||||
return {"ok": False, "error": str(exc), "cmd": cmd}
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"code": proc.returncode,
|
||||
"stdout": (proc.stdout or "").strip(),
|
||||
"stderr": (proc.stderr or "").strip(),
|
||||
"cmd": cmd,
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(name: str) -> bool:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS ok
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
return bool(row and row.get("ok"))
|
||||
|
||||
|
||||
def _record_backup(
|
||||
status: str,
|
||||
message: str,
|
||||
files_count: int = 0,
|
||||
commit_ref: str | None = None,
|
||||
approval_request_id: int | None = None,
|
||||
payload: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not _table_exists("config_backups"):
|
||||
return None
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
approval_request_id,
|
||||
commit_ref,
|
||||
files_count,
|
||||
status,
|
||||
message,
|
||||
json.dumps(payload or {}),
|
||||
),
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_backups(limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not _table_exists("config_backups"):
|
||||
return []
|
||||
rows = fetch_all(
|
||||
"SELECT * FROM config_backups ORDER BY created_at DESC LIMIT %s",
|
||||
(max(1, min(limit, 100)),),
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if hasattr(item.get("created_at"), "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def prepare_backup_manifest() -> dict[str, Any]:
|
||||
root = BACKUP_ROOT
|
||||
files: list[str] = []
|
||||
if root.exists():
|
||||
for rel in BACKUP_PATHS:
|
||||
p = root / rel
|
||||
if p.is_file():
|
||||
files.append(rel)
|
||||
elif p.is_dir():
|
||||
for fp in p.rglob("*"):
|
||||
if fp.is_file() and not any(x in str(fp) for x in [".git", "__pycache__", ".pyc"]):
|
||||
files.append(str(fp.relative_to(root)))
|
||||
return {
|
||||
"root": str(root),
|
||||
"root_exists": root.exists(),
|
||||
"files": sorted(files)[:500],
|
||||
"files_count": len(files),
|
||||
"paths_configured": BACKUP_PATHS,
|
||||
"generated_at": _iso_now(),
|
||||
}
|
||||
|
||||
|
||||
def run_gitea_backup(approval_request_id: int | None = None) -> dict[str, Any]:
|
||||
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
|
||||
|
||||
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=approval_request_id)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Volledige codebase sync naar Gitea main — SysOps."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.middleware import log_agent_event
|
||||
|
||||
REPO_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
|
||||
GITEA_REMOTE = os.getenv(
|
||||
"GITEA_BACKUP_REMOTE",
|
||||
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
|
||||
)
|
||||
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
|
||||
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
|
||||
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
|
||||
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
|
||||
|
||||
SKIP_NAMES = {
|
||||
".git",
|
||||
"__pycache__",
|
||||
".env",
|
||||
"node_modules",
|
||||
".cursor",
|
||||
".venv",
|
||||
"venv",
|
||||
"*.pyc",
|
||||
}
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 180) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "timeout", "cmd": cmd}
|
||||
except FileNotFoundError as exc:
|
||||
return {"ok": False, "error": str(exc), "cmd": cmd}
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"code": proc.returncode,
|
||||
"stdout": (proc.stdout or "").strip(),
|
||||
"stderr": (proc.stderr or "").strip(),
|
||||
"cmd": cmd,
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(name: str) -> bool:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS ok
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
return bool(row and row.get("ok"))
|
||||
|
||||
|
||||
def _record_activity(
|
||||
action_type: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
commit_ref: str | None = None,
|
||||
files_changed: int = 0,
|
||||
status: str = "success",
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not _table_exists("sysops_activity"):
|
||||
return None
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO sysops_activity (action_type, title, body, commit_ref, files_changed, status, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(action_type, title, body, commit_ref, files_changed, status, json.dumps(metadata or {})),
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_activity(limit: int = 30) -> list[dict[str, Any]]:
|
||||
if not _table_exists("sysops_activity"):
|
||||
return []
|
||||
rows = fetch_all(
|
||||
"SELECT * FROM sysops_activity ORDER BY created_at DESC LIMIT %s",
|
||||
(max(1, min(limit, 100)),),
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if hasattr(item.get("created_at"), "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _should_skip(name: str) -> bool:
|
||||
if name in SKIP_NAMES:
|
||||
return True
|
||||
if name.endswith(".pyc") or name.endswith(".pyo"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _copy_tree(src: Path, dest: Path) -> int:
|
||||
"""Kopieer repo-inhoud naar git worktree; retour aantal bestanden."""
|
||||
count = 0
|
||||
if not src.exists():
|
||||
return 0
|
||||
for item in src.iterdir():
|
||||
if _should_skip(item.name):
|
||||
continue
|
||||
target = dest / item.name
|
||||
if item.is_file():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, target)
|
||||
count += 1
|
||||
elif item.is_dir():
|
||||
if target.exists():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
shutil.copytree(
|
||||
item,
|
||||
target,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".env", "node_modules", ".git"),
|
||||
)
|
||||
count += sum(1 for _ in target.rglob("*") if _.is_file())
|
||||
return count
|
||||
|
||||
|
||||
def run_gitea_repo_sync(reason: str = "deploy", approval_request_id: int | None = None) -> dict[str, Any]:
|
||||
"""Push volledige codebase naar Gitea main branch."""
|
||||
work = Path("/tmp/foodlinkk-gitea-sync")
|
||||
if work.exists():
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not REPO_ROOT.exists():
|
||||
msg = f"Repo root niet gevonden: {REPO_ROOT}"
|
||||
_record_activity("gitea_sync", "Gitea sync mislukt", msg, status="failed")
|
||||
return {"ok": False, "message": msg}
|
||||
|
||||
repo = work / "repo"
|
||||
clone = _run(["git", "clone", "--depth", "1", "-b", GITEA_BRANCH, GITEA_REMOTE, str(repo)])
|
||||
if not clone.get("ok"):
|
||||
repo = work
|
||||
_run(["git", "init", "-b", GITEA_BRANCH], cwd=repo)
|
||||
_run(["git", "remote", "add", "origin", GITEA_REMOTE], cwd=repo)
|
||||
|
||||
_run(["git", "config", "user.email", GITEA_GIT_EMAIL], cwd=repo)
|
||||
_run(["git", "config", "user.name", GITEA_GIT_NAME], cwd=repo)
|
||||
|
||||
copied = _copy_tree(REPO_ROOT, repo)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
commit_msg = f"SysOps: {reason} — {stamp}"
|
||||
|
||||
_run(["git", "add", "-A"], cwd=repo)
|
||||
commit = _run(["git", "commit", "-m", commit_msg], cwd=repo)
|
||||
nothing = "nothing to commit" in (commit.get("stdout", "") + commit.get("stderr", ""))
|
||||
if not commit.get("ok") and not nothing:
|
||||
msg = "Git commit mislukt: " + (commit.get("stderr") or "")
|
||||
_record_activity("gitea_sync", "Gitea commit mislukt", msg, files_changed=copied, status="failed")
|
||||
return {"ok": False, "message": msg, "copied": copied}
|
||||
|
||||
commit_ref = None
|
||||
if not nothing:
|
||||
push = _run(["git", "push", "-u", "origin", GITEA_BRANCH], cwd=repo, timeout=300)
|
||||
if not push.get("ok"):
|
||||
msg = "Git push mislukt: " + (push.get("stderr") or "")
|
||||
_record_activity("gitea_sync", "Gitea push mislukt", msg, files_changed=copied, status="failed")
|
||||
return {"ok": False, "message": msg, "copied": copied}
|
||||
rev = _run(["git", "rev-parse", "--short", "HEAD"], cwd=repo)
|
||||
commit_ref = rev.get("stdout") or None
|
||||
|
||||
summary = (
|
||||
f"Volledige codebase gesynchroniseerd naar Gitea ({reason}). "
|
||||
f"{copied} bestanden · commit {commit_ref or 'geen wijzigingen'}"
|
||||
)
|
||||
activity = _record_activity(
|
||||
"gitea_sync",
|
||||
f"Gitea push: {reason}",
|
||||
summary,
|
||||
commit_ref=commit_ref,
|
||||
files_changed=copied,
|
||||
status="success" if (commit_ref or nothing) else "skipped",
|
||||
metadata={"reason": reason, "approval_request_id": approval_request_id, "nothing_to_commit": nothing},
|
||||
)
|
||||
|
||||
try:
|
||||
log_agent_event(
|
||||
agent_name="sysops",
|
||||
agent_type="sysops",
|
||||
event_type="gitea_sync",
|
||||
title=f"Gitea sync: {reason}" + (f" ({commit_ref})" if commit_ref else " — geen wijzigingen"),
|
||||
body=summary,
|
||||
metadata={
|
||||
"commit_ref": commit_ref,
|
||||
"files_changed": copied,
|
||||
"reason": reason,
|
||||
"for_herman": True,
|
||||
},
|
||||
channel="sysops",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _table_exists("config_backups"):
|
||||
fetch_one(
|
||||
"""
|
||||
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
approval_request_id,
|
||||
commit_ref,
|
||||
copied,
|
||||
"success" if commit_ref else "unchanged",
|
||||
summary,
|
||||
json.dumps({"reason": reason, "full_repo": True}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": summary,
|
||||
"commit_ref": commit_ref,
|
||||
"files_count": copied,
|
||||
"nothing_to_commit": nothing,
|
||||
"activity_id": (activity or {}).get("id"),
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Daily maintenance checks for IT Ops tab."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.connectors.proxmox import PROXMOX_HOST, VM_106_IP, _port_health, _run_ssh, check_docker_services
|
||||
|
||||
SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _table_exists(name: str) -> bool:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS ok
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
return bool(row and row.get("ok"))
|
||||
|
||||
|
||||
def list_notes(limit: int = 20, unresolved_only: bool = True) -> list[dict[str, Any]]:
|
||||
if not _table_exists("ops_maintenance_notes"):
|
||||
return []
|
||||
clauses = ["resolved = false"] if unresolved_only else []
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
rows = fetch_all(
|
||||
f"SELECT * FROM ops_maintenance_notes{where} ORDER BY created_at DESC LIMIT %s",
|
||||
(max(1, min(limit, 100)),),
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if hasattr(item.get("created_at"), "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def add_note(title: str, body: str = "", severity: str = "info", source: str = "sysops") -> dict[str, Any] | None:
|
||||
if not _table_exists("ops_maintenance_notes"):
|
||||
return None
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO ops_maintenance_notes (severity, title, body, source)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(severity, title[:255], body, source),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
item = dict(row)
|
||||
if hasattr(item.get("created_at"), "isoformat"):
|
||||
item["created_at"] = item["created_at"].isoformat()
|
||||
return item
|
||||
|
||||
|
||||
def run_maintenance_scan() -> dict[str, Any]:
|
||||
findings: list[dict[str, Any]] = []
|
||||
|
||||
proxmox_up = _port_health(PROXMOX_HOST, 8006)
|
||||
if not proxmox_up:
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"title": "Proxmox Web UI niet bereikbaar",
|
||||
"body": f"Poort 8006 op {PROXMOX_HOST} reageert niet. Controleer Dell R340 / Proxmox VE.",
|
||||
})
|
||||
|
||||
docker = check_docker_services()
|
||||
if not docker.get("ok"):
|
||||
findings.append({
|
||||
"severity": "warning",
|
||||
"title": "Docker status onbekend",
|
||||
"body": docker.get("error") or "Kon docker ps niet uitvoeren op VM106.",
|
||||
})
|
||||
else:
|
||||
containers = docker.get("containers") or []
|
||||
unhealthy = [
|
||||
c for c in containers
|
||||
if "unhealthy" in str(c.get("Status") or c.get("State") or "").lower()
|
||||
]
|
||||
if unhealthy:
|
||||
names = ", ".join(str(c.get("Names") or c.get("Name") or "?")[:40] for c in unhealthy[:5])
|
||||
findings.append({
|
||||
"severity": "warning",
|
||||
"title": f"{len(unhealthy)} container(s) unhealthy",
|
||||
"body": names,
|
||||
})
|
||||
|
||||
apt = _run_ssh(VM_106_IP, "apt list --upgradable 2>/dev/null | grep -v Listing | wc -l")
|
||||
if apt.get("ok"):
|
||||
try:
|
||||
count = int((apt.get("stdout") or "0").strip())
|
||||
except ValueError:
|
||||
count = 0
|
||||
if count > 0:
|
||||
findings.append({
|
||||
"severity": "info" if count < 10 else "warning",
|
||||
"title": f"{count} package updates beschikbaar op VM106",
|
||||
"body": "Voer maintenance uit wanneer gepland: apt update && apt upgrade",
|
||||
})
|
||||
|
||||
disk = _run_ssh(VM_106_IP, "df -h / | tail -1 | awk '{print $5}'")
|
||||
if disk.get("ok"):
|
||||
pct = (disk.get("stdout") or "").replace("%", "").strip()
|
||||
try:
|
||||
used = int(pct)
|
||||
if used >= 90:
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"title": f"Schijf VM106 {used}% vol",
|
||||
"body": "Ruimte vrijmaken op root volume.",
|
||||
})
|
||||
elif used >= 80:
|
||||
findings.append({
|
||||
"severity": "warning",
|
||||
"title": f"Schijf VM106 {used}% vol",
|
||||
"body": "Plan opschoning binnenkort.",
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
saved: list[dict[str, Any]] = []
|
||||
for f in findings:
|
||||
note = add_note(f["title"], f.get("body", ""), f.get("severity", "info"))
|
||||
if note:
|
||||
saved.append(note)
|
||||
|
||||
if not findings:
|
||||
note = add_note(
|
||||
"Geen openstaande maintenance — alles OK",
|
||||
f"Scan {_iso_now()}: Proxmox, Docker en schijf binnen normen.",
|
||||
"info",
|
||||
)
|
||||
if note:
|
||||
saved.append(note)
|
||||
|
||||
saved.sort(key=lambda n: SEVERITY_ORDER.get(str(n.get("severity")), 9))
|
||||
|
||||
try:
|
||||
from app.connectors.gitea_repo_sync import _record_activity
|
||||
from app.middleware import log_agent_event
|
||||
|
||||
summary = f"{len(findings)} bevinding(en) — scan {_iso_now()[:19]}"
|
||||
_record_activity(
|
||||
"maintenance_scan",
|
||||
f"Maintenance scan: {len(findings)} bevinding(en)",
|
||||
summary + "\n" + "\n".join(f"- {f['title']}" for f in findings[:8]),
|
||||
metadata={"findings_count": len(findings), "for_herman": True},
|
||||
)
|
||||
log_agent_event(
|
||||
agent_name="sysops",
|
||||
agent_type="sysops",
|
||||
event_type="maintenance_scan",
|
||||
title=f"Maintenance scan voltooid — {len(findings)} bevinding(en)",
|
||||
body=summary,
|
||||
metadata={"findings": [f.get("title") for f in findings[:10]], "for_herman": True},
|
||||
channel="sysops",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"scanned_at": _iso_now(),
|
||||
"findings_count": len(findings),
|
||||
"notes": saved,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Register agent outputs as project assets (shared DB helpers)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_one
|
||||
|
||||
|
||||
def get_or_create_agent_project(agent_key: str, client_id: int | None = None) -> int:
|
||||
key = (agent_key or "agent").strip().lower()
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT id FROM cockpit_projects
|
||||
WHERE metadata->>'auto_agent' = %s AND status = 'active'
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
title = f"Agent · {key}"
|
||||
created = fetch_one(
|
||||
"""
|
||||
INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
title,
|
||||
client_id,
|
||||
f"Automatisch project voor {key} output",
|
||||
key,
|
||||
json.dumps({"auto_agent": key}),
|
||||
),
|
||||
)
|
||||
return int(created["id"])
|
||||
|
||||
|
||||
def register_agent_output(
|
||||
asset_type: str,
|
||||
title: str,
|
||||
ref_id: str | None = None,
|
||||
payload: dict | None = None,
|
||||
project_id: int | None = None,
|
||||
source_agent: str | None = None,
|
||||
created_by: str | None = None,
|
||||
file_path: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
agent = (source_agent or created_by or "agent").strip().lower()
|
||||
pid = project_id or get_or_create_agent_project(agent)
|
||||
|
||||
if ref_id:
|
||||
existing = fetch_one(
|
||||
"""
|
||||
SELECT id FROM project_assets
|
||||
WHERE project_id = %s AND asset_type = %s AND ref_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(pid, asset_type, ref_id),
|
||||
)
|
||||
if existing:
|
||||
return {"id": existing["id"], "project_id": pid, "dedup": True}
|
||||
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
RETURNING id, project_id, asset_type, ref_id, title
|
||||
""",
|
||||
(
|
||||
pid,
|
||||
asset_type,
|
||||
ref_id,
|
||||
title[:255],
|
||||
file_path,
|
||||
json.dumps(payload or {}),
|
||||
created_by or agent,
|
||||
agent,
|
||||
),
|
||||
)
|
||||
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (pid,))
|
||||
return dict(row) if row else None
|
||||
+157
-178
@@ -1,4 +1,4 @@
|
||||
"""Proxmox infrastructure monitoring connector for Foodlinkk IT Ops."""
|
||||
"""Proxmox infrastructure monitoring — Dell R340 · Proxmox VE · VM106 services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -15,20 +15,30 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
PROXMOX_HOST = "10.4.7.14"
|
||||
PROXMOX_HOST = os.getenv("PROXMOX_HOST", "10.4.7.14")
|
||||
PROXMOX_API_URL = f"https://{PROXMOX_HOST}:8006/api2/json"
|
||||
SSH_USER = "aissa"
|
||||
SSH_PASSWORD = "Foodlinkk#2026"
|
||||
SSH_USER = os.getenv("PROXMOX_SSH_USER", "aissa")
|
||||
SSH_PASSWORD = os.getenv("PROXMOX_SSH_PASS", "Foodlinkk#2026")
|
||||
|
||||
VM_105_IP = "10.4.7.19"
|
||||
VM_106_IP = "10.4.7.18"
|
||||
VM_106_IP = os.getenv("VM106_IP", "10.4.7.18")
|
||||
VM_106_ID = 106
|
||||
VM_106_NAME = "dockervm"
|
||||
|
||||
SERVICE_LAYOUT: list[dict[str, Any]] = [
|
||||
{"id": "svc-cockpit", "label": "cockpit:8600", "host": VM_106_IP, "parent": "vm106-command", "port": 8600},
|
||||
{"id": "svc-tools-api", "label": "tools-api:8700", "host": VM_106_IP, "parent": "vm106-command", "port": 8700},
|
||||
{"id": "svc-email-agent", "label": "email-agent:8801", "host": VM_106_IP, "parent": "vm106-command", "port": 8801},
|
||||
{"id": "svc-gitea", "label": "gitea:3001", "host": VM_105_IP, "parent": "vm105-hermes", "port": 3001},
|
||||
{"id": "svc-ollama", "label": "ollama:11434", "host": VM_105_IP, "parent": "vm105-hermes", "port": 11434},
|
||||
HARDWARE_LABEL = "Dell PowerEdge R340"
|
||||
|
||||
SERVICE_CATALOG: list[dict[str, Any]] = [
|
||||
{"id": "svc-cockpit", "label": "Cockpit", "port": 8600, "url": f"http://{VM_106_IP}:8600", "role": "Command Center UI"},
|
||||
{"id": "svc-tools-api", "label": "Tools API", "port": 8700, "url": f"http://{VM_106_IP}:8700/docs", "role": "Connectors & API"},
|
||||
{"id": "svc-gitea", "label": "Gitea", "port": 3001, "url": f"http://{VM_106_IP}:3001", "role": "Git · config backups"},
|
||||
{"id": "svc-email-agent", "label": "Email Agent", "port": 8801, "url": f"http://{VM_106_IP}:8801", "role": "IMAP/SMTP sync"},
|
||||
{"id": "svc-grafana", "label": "Grafana", "port": 3002, "url": f"http://{VM_106_IP}:3002", "role": "Metrics dashboards"},
|
||||
{"id": "svc-prometheus", "label": "Prometheus", "port": 9090, "url": f"http://{VM_106_IP}:9090", "role": "Metrics scrape"},
|
||||
{"id": "svc-browser-agent", "label": "Browser Agent", "port": 7790, "url": f"http://{VM_106_IP}:7790", "role": "Web automation"},
|
||||
]
|
||||
|
||||
LAYER_AGENTS: list[dict[str, Any]] = [
|
||||
{"id": "mon-sysops", "label": "SysOps", "agent_key": "sysops", "role": "Backup · Proxmox · Docker · maintenance"},
|
||||
{"id": "mon-research", "label": "Research", "agent_key": "research", "role": "Markt intel (approval gate)"},
|
||||
]
|
||||
|
||||
|
||||
@@ -38,26 +48,16 @@ def _iso_now() -> str:
|
||||
|
||||
def _run_ssh(host: str, command: str, timeout: int = 12) -> dict[str, Any]:
|
||||
ssh_cmd = [
|
||||
"sshpass",
|
||||
"-p",
|
||||
SSH_PASSWORD,
|
||||
"ssh",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=7",
|
||||
"sshpass", "-p", SSH_PASSWORD, "ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=7",
|
||||
f"{SSH_USER}@{host}",
|
||||
command,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
ssh_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
ssh_cmd, capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return {"ok": False, "error": f"ssh tooling missing: {exc}"}
|
||||
@@ -77,15 +77,12 @@ def _http_get_json(url: str, headers: dict[str, str] | None = None, timeout: int
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
with urlopen(req, timeout=timeout, context=ctx) as resp: # noqa: S310
|
||||
payload = resp.read().decode("utf-8")
|
||||
return json.loads(payload)
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _build_token_header(token_value: str) -> str:
|
||||
val = token_value.strip()
|
||||
if val.startswith("PVEAPIToken="):
|
||||
return val
|
||||
return f"PVEAPIToken={val}"
|
||||
return val if val.startswith("PVEAPIToken=") else f"PVEAPIToken={val}"
|
||||
|
||||
|
||||
def _create_api_token_via_ssh() -> str | None:
|
||||
@@ -101,68 +98,62 @@ def _create_api_token_via_ssh() -> str | None:
|
||||
parsed = json.loads(result.get("stdout") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
tokenid = parsed.get("full-tokenid")
|
||||
secret = parsed.get("value")
|
||||
if tokenid and secret:
|
||||
return f"PVEAPIToken={tokenid}={secret}"
|
||||
return None
|
||||
tokenid, secret = parsed.get("full-tokenid"), parsed.get("value")
|
||||
return f"PVEAPIToken={tokenid}={secret}" if tokenid and secret else None
|
||||
|
||||
|
||||
def _fetch_nodes_via_api() -> tuple[list[dict[str, Any]], str, str | None]:
|
||||
def _fetch_proxmox_state() -> tuple[str, float, float, str, str, str | None]:
|
||||
token = os.getenv("PROXMOX_TOKEN")
|
||||
tried = []
|
||||
api_source, api_error = "none", None
|
||||
nodes: list[dict[str, Any]] = []
|
||||
|
||||
if token:
|
||||
tried.append("env-token")
|
||||
try:
|
||||
data = _http_get_json(
|
||||
f"{PROXMOX_API_URL}/nodes",
|
||||
headers={"Authorization": _build_token_header(token)},
|
||||
)
|
||||
return data.get("data") or [], "api-token-env", None
|
||||
data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": _build_token_header(token)})
|
||||
nodes = data.get("data") or []
|
||||
api_source = "api-token-env"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append(f"env-failed:{exc}")
|
||||
created = _create_api_token_via_ssh()
|
||||
if created:
|
||||
tried.append("ssh-created-token")
|
||||
try:
|
||||
data = _http_get_json(
|
||||
f"{PROXMOX_API_URL}/nodes",
|
||||
headers={"Authorization": created},
|
||||
)
|
||||
return data.get("data") or [], "api-token-ssh", None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append(f"ssh-token-failed:{exc}")
|
||||
return [], "none", ", ".join(tried) if tried else "no-token"
|
||||
api_error = str(exc)
|
||||
|
||||
if not nodes:
|
||||
created = _create_api_token_via_ssh()
|
||||
if created:
|
||||
try:
|
||||
data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": created})
|
||||
nodes = data.get("data") or []
|
||||
api_source = "api-token-ssh"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
api_error = str(exc)
|
||||
|
||||
def _fetch_nodes_via_ssh() -> tuple[list[dict[str, Any]], str, str | None]:
|
||||
pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json")
|
||||
if pvesh.get("ok"):
|
||||
try:
|
||||
return json.loads(pvesh["stdout"] or "[]"), "ssh-pvesh", None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
qm = _run_ssh(PROXMOX_HOST, "qm list")
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not nodes:
|
||||
pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json")
|
||||
if pvesh.get("ok"):
|
||||
try:
|
||||
nodes = json.loads(pvesh["stdout"] or "[]")
|
||||
api_source = "ssh-pvesh"
|
||||
except json.JSONDecodeError:
|
||||
api_error = pvesh.get("stderr") or "pvesh parse error"
|
||||
|
||||
api_node = next((n for n in nodes if isinstance(n, dict) and (n.get("node") or "").strip()), None)
|
||||
cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
|
||||
mem = float(api_node.get("mem", 0)) if api_node else 0.0
|
||||
status = str(api_node.get("status") or "unknown") if api_node else "unknown"
|
||||
|
||||
vm_status = "unknown"
|
||||
qm = _run_ssh(PROXMOX_HOST, f"qm status {VM_106_ID} 2>/dev/null || qm list | grep '^{VM_106_ID} '")
|
||||
if qm.get("ok") and qm.get("stdout"):
|
||||
lines = (qm["stdout"] or "").splitlines()
|
||||
for line in lines[1:]:
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
vmid = parts[0]
|
||||
rows.append(
|
||||
{
|
||||
"node": "pve",
|
||||
"type": "qemu",
|
||||
"id": f"qemu/{vmid}",
|
||||
"vmid": int(vmid) if vmid.isdigit() else vmid,
|
||||
"status": parts[2] if len(parts) > 2 else "unknown",
|
||||
}
|
||||
)
|
||||
return rows, "ssh-qm-list", None
|
||||
err = pvesh.get("stderr") or qm.get("stderr") or "ssh lookup failed"
|
||||
return [], "none", err
|
||||
line = qm["stdout"].splitlines()[0].lower()
|
||||
if "running" in line:
|
||||
vm_status = "running"
|
||||
elif "stopped" in line:
|
||||
vm_status = "stopped"
|
||||
else:
|
||||
vm_status = "online" if _port_health(VM_106_IP, 22) else "offline"
|
||||
|
||||
if status == "unknown" and _port_health(PROXMOX_HOST, 8006):
|
||||
status = "online"
|
||||
|
||||
return status, cpu, mem, vm_status, api_source, api_error
|
||||
|
||||
|
||||
def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
|
||||
@@ -176,13 +167,10 @@ def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
|
||||
|
||||
def check_docker_services() -> dict[str, Any]:
|
||||
result = _run_ssh(VM_106_IP, "docker ps --format json")
|
||||
method = "docker-ps-json"
|
||||
if not result.get("ok"):
|
||||
result = _run_ssh(VM_106_IP, "docker ps --format '{{json .}}'")
|
||||
method = "docker-ps-template-json"
|
||||
if not result.get("ok"):
|
||||
return {"ok": False, "source": method, "error": result.get("stderr") or "docker check failed", "containers": []}
|
||||
|
||||
return {"ok": False, "error": result.get("stderr") or "docker check failed", "containers": []}
|
||||
containers: list[dict[str, Any]] = []
|
||||
for line in (result.get("stdout") or "").splitlines():
|
||||
line = line.strip()
|
||||
@@ -193,102 +181,99 @@ def check_docker_services() -> dict[str, Any]:
|
||||
containers.append(parsed if isinstance(parsed, dict) else {"raw": parsed})
|
||||
except json.JSONDecodeError:
|
||||
containers.append({"raw": line})
|
||||
return {"ok": True, "source": method, "containers": containers}
|
||||
return {"ok": True, "containers": containers}
|
||||
|
||||
|
||||
def _online_services() -> list[dict[str, Any]]:
|
||||
docker_state = check_docker_services()
|
||||
docker_names = {
|
||||
str(c.get("Names") or c.get("Name") or "").lower(): c
|
||||
for c in docker_state.get("containers", [])
|
||||
}
|
||||
online: list[dict[str, Any]] = []
|
||||
for svc in SERVICE_CATALOG:
|
||||
up = _port_health(VM_106_IP, int(svc["port"]))
|
||||
if not up:
|
||||
continue
|
||||
hint = "running"
|
||||
for name, details in docker_names.items():
|
||||
slug = svc["id"].replace("svc-", "").replace("-", "")
|
||||
if slug in name.replace("-", ""):
|
||||
hint = str(details.get("State") or details.get("Status") or "running")
|
||||
break
|
||||
online.append({
|
||||
"id": svc["id"],
|
||||
"label": f"{svc['label']}:{svc['port']}",
|
||||
"type": "service",
|
||||
"status": "online",
|
||||
"host": VM_106_IP,
|
||||
"port": svc["port"],
|
||||
"url": svc["url"],
|
||||
"role": svc["role"],
|
||||
"hint": hint,
|
||||
"agent": "sysops",
|
||||
"children": [],
|
||||
})
|
||||
return online
|
||||
|
||||
|
||||
def get_topology() -> dict[str, Any]:
|
||||
api_nodes, api_source, api_error = _fetch_nodes_via_api()
|
||||
ssh_nodes: list[dict[str, Any]] = []
|
||||
ssh_source = "none"
|
||||
ssh_error: str | None = None
|
||||
if not api_nodes:
|
||||
ssh_nodes, ssh_source, ssh_error = _fetch_nodes_via_ssh()
|
||||
|
||||
api_node = next((n for n in api_nodes if (n.get("node") or "").strip()), None) if api_nodes else None
|
||||
host_cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
|
||||
host_mem = float(api_node.get("mem", 0)) if api_node else 0.0
|
||||
host_status = api_node.get("status") if api_node else "unknown"
|
||||
if host_status == "unknown" and ssh_nodes:
|
||||
host_status = "online"
|
||||
|
||||
vm_states: dict[str, str] = {"105": "unknown", "106": "unknown"}
|
||||
source_rows = api_nodes or ssh_nodes
|
||||
for row in source_rows:
|
||||
vmid = str(row.get("vmid") or "").strip()
|
||||
if vmid in vm_states:
|
||||
vm_states[vmid] = str(row.get("status") or "unknown")
|
||||
|
||||
docker_state = check_docker_services()
|
||||
docker_names = {
|
||||
str(c.get("Names") or c.get("Names.0") or c.get("Name") or "").lower(): c for c in docker_state.get("containers", [])
|
||||
}
|
||||
|
||||
vm105_children: list[dict[str, Any]] = []
|
||||
vm106_children: list[dict[str, Any]] = []
|
||||
for svc in SERVICE_LAYOUT:
|
||||
up = _port_health(str(svc["host"]), int(svc["port"]))
|
||||
hinted = "unknown"
|
||||
for name, details in docker_names.items():
|
||||
if svc["label"].split(":")[0].replace("-", "") in name.replace("-", ""):
|
||||
hinted = str(details.get("State") or details.get("Status") or "running")
|
||||
break
|
||||
item = {
|
||||
"id": svc["id"],
|
||||
"label": svc["label"],
|
||||
"type": "service",
|
||||
"status": "online" if up else "offline",
|
||||
"cpu": None,
|
||||
"mem": None,
|
||||
"host": svc["host"],
|
||||
"hint": hinted,
|
||||
"children": [],
|
||||
}
|
||||
if svc["parent"] == "vm105-hermes":
|
||||
vm105_children.append(item)
|
||||
else:
|
||||
vm106_children.append(item)
|
||||
host_status, host_cpu, host_mem, vm_status, api_source, api_error = _fetch_proxmox_state()
|
||||
services = _online_services()
|
||||
proxmox_reachable = _port_health(PROXMOX_HOST, 8006)
|
||||
|
||||
topology_nodes = [
|
||||
{
|
||||
"id": "proxmox-host",
|
||||
"label": f"proxmox-host ({PROXMOX_HOST})",
|
||||
"type": "proxmox",
|
||||
"status": host_status,
|
||||
"cpu": host_cpu,
|
||||
"mem": host_mem,
|
||||
"id": "hardware-r340",
|
||||
"label": HARDWARE_LABEL,
|
||||
"type": "hardware",
|
||||
"status": "online" if proxmox_reachable or _port_health(VM_106_IP, 8600) else "degraded",
|
||||
"role": "Bare metal host",
|
||||
"children": [
|
||||
{
|
||||
"id": "vm105-hermes",
|
||||
"label": f"vm105-hermes ({VM_105_IP})",
|
||||
"type": "vm",
|
||||
"status": vm_states["105"],
|
||||
"cpu": None,
|
||||
"mem": None,
|
||||
"children": vm105_children,
|
||||
},
|
||||
{
|
||||
"id": "vm106-command",
|
||||
"label": f"vm106-command ({VM_106_IP})",
|
||||
"type": "vm",
|
||||
"status": vm_states["106"],
|
||||
"cpu": None,
|
||||
"mem": None,
|
||||
"children": vm106_children,
|
||||
},
|
||||
"id": "proxmox-host",
|
||||
"label": f"Proxmox VE · {PROXMOX_HOST}",
|
||||
"type": "proxmox",
|
||||
"status": host_status if proxmox_reachable else ("online" if _port_health(PROXMOX_HOST, 22) else "offline"),
|
||||
"cpu": host_cpu,
|
||||
"mem": host_mem,
|
||||
"role": "Hypervisor · node pve",
|
||||
"url": f"https://{PROXMOX_HOST}:8006",
|
||||
"children": [
|
||||
{
|
||||
"id": "vm106-command",
|
||||
"label": f"VM {VM_106_ID} · {VM_106_NAME} · {VM_106_IP}",
|
||||
"type": "vm",
|
||||
"status": vm_status if vm_status != "unknown" else ("online" if services else "offline"),
|
||||
"vmid": VM_106_ID,
|
||||
"role": "Foodlinkk Command Center",
|
||||
"children": services,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
"generated_at": _iso_now(),
|
||||
"nodes": topology_nodes,
|
||||
"layer_agents": LAYER_AGENTS,
|
||||
"layers": [
|
||||
{"id": "L0", "label": "Hardware", "y": 55},
|
||||
{"id": "L1", "label": "Hypervisor", "y": 175},
|
||||
{"id": "L2", "label": f"VM {VM_106_ID}", "y": 295},
|
||||
{"id": "L3", "label": "Online services", "y": 415},
|
||||
{"id": "L4", "label": "IT Agents", "y": 555},
|
||||
],
|
||||
"meta": {
|
||||
"hardware": HARDWARE_LABEL,
|
||||
"proxmox_host": PROXMOX_HOST,
|
||||
"vm106_ip": VM_106_IP,
|
||||
"vm106_id": VM_106_ID,
|
||||
"services_online": len(services),
|
||||
"api_source": api_source,
|
||||
"api_error": api_error,
|
||||
"ssh_source": ssh_source,
|
||||
"ssh_error": ssh_error,
|
||||
"docker_source": docker_state.get("source"),
|
||||
"docker_ok": docker_state.get("ok", False),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -322,10 +307,8 @@ def _table_exists(table_name: str) -> bool:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = %s
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS ok
|
||||
""",
|
||||
(table_name,),
|
||||
@@ -340,10 +323,8 @@ def poll_and_snapshot() -> dict[str, Any]:
|
||||
|
||||
cols = fetch_all(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'infra_snapshots'
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'infra_snapshots'
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
)
|
||||
@@ -354,7 +335,6 @@ def poll_and_snapshot() -> dict[str, Any]:
|
||||
"summary": {k: v for k, v in status.items() if k != "topology"},
|
||||
"generated_at": status.get("generated_at"),
|
||||
}
|
||||
|
||||
value_map: dict[str, Any] = {}
|
||||
if "source" in colset:
|
||||
value_map["source"] = "proxmox"
|
||||
@@ -375,7 +355,6 @@ def poll_and_snapshot() -> dict[str, Any]:
|
||||
return {"ok": False, "saved": False, "reason": "infra_snapshots has no compatible columns", "status": status}
|
||||
|
||||
columns = list(value_map.keys())
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({placeholders})"
|
||||
sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({', '.join([ '%s' ] * len(columns))})"
|
||||
execute(sql, tuple(value_map[c] for c in columns))
|
||||
return {"ok": True, "saved": True, "columns": columns, "status": status}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.connectors.config_backup import list_backups, prepare_backup_manifest
|
||||
from app.connectors.gitea_repo_sync import list_activity, run_gitea_repo_sync
|
||||
from app.connectors.ops_maintenance import list_notes, run_maintenance_scan
|
||||
from app.connectors.proxmox import get_status_summary, get_topology, poll_and_snapshot
|
||||
|
||||
router = APIRouter(prefix="/ops", tags=["ops"])
|
||||
|
||||
|
||||
class BackupRunBody(BaseModel):
|
||||
approval_request_id: int | None = None
|
||||
|
||||
|
||||
class GiteaSyncBody(BaseModel):
|
||||
reason: str = Field(default="manual", max_length=120)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def ops_status() -> dict:
|
||||
try:
|
||||
@@ -29,3 +41,42 @@ def ops_refresh() -> dict:
|
||||
return poll_and_snapshot()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"ops refresh failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/backups")
|
||||
def ops_backups(limit: int = 10) -> dict:
|
||||
return {"items": list_backups(limit), "manifest": prepare_backup_manifest()}
|
||||
|
||||
|
||||
@router.post("/backup/run")
|
||||
def ops_backup_run(body: BackupRunBody) -> dict:
|
||||
try:
|
||||
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=body.approval_request_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/gitea/sync")
|
||||
def ops_gitea_sync(body: GiteaSyncBody) -> dict:
|
||||
try:
|
||||
return run_gitea_repo_sync(reason=body.reason)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/sysops-activity")
|
||||
def ops_sysops_activity(limit: int = 30) -> dict:
|
||||
return {"items": list_activity(limit), "count": len(list_activity(limit))}
|
||||
|
||||
|
||||
@router.get("/maintenance")
|
||||
def ops_maintenance(limit: int = 15) -> dict:
|
||||
return {"items": list_notes(limit, unresolved_only=True)}
|
||||
|
||||
|
||||
@router.post("/maintenance/scan")
|
||||
def ops_maintenance_scan() -> dict:
|
||||
try:
|
||||
return run_maintenance_scan()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""SVG packaging generator for Foodlinkk."""
|
||||
"""SVG packaging generator for Foodlinkk — rich design options."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -9,8 +9,7 @@ import svgwrite
|
||||
from barcode import Code128
|
||||
from barcode.writer import SVGWriter
|
||||
|
||||
|
||||
MM_TO_PX = 3.7795275591 # 96 DPI conversion
|
||||
MM_TO_PX = 3.7795275591
|
||||
DEFAULT_BARCODE_VALUE = "8710000000012"
|
||||
|
||||
FOODLINKK_BRAND = {
|
||||
@@ -18,11 +17,24 @@ FOODLINKK_BRAND = {
|
||||
"panel": "#101a2d",
|
||||
"primary": "#00e5ff",
|
||||
"secondary": "#ffd700",
|
||||
"accent": "#b8ff3c",
|
||||
"text": "#e2e8f0",
|
||||
"muted": "#94a3b8",
|
||||
"cut_line": "#ef4444",
|
||||
"fold_line": "#60a5fa",
|
||||
"bleed": "#f97316",
|
||||
"halal": "#22c55e",
|
||||
}
|
||||
|
||||
PACKAGING_TYPES = (
|
||||
"folding_box",
|
||||
"wrap",
|
||||
"round_label",
|
||||
"sleeve",
|
||||
"pouch",
|
||||
"tray",
|
||||
)
|
||||
|
||||
|
||||
def _mm(mm: float) -> float:
|
||||
return round(float(mm) * MM_TO_PX, 2)
|
||||
@@ -44,32 +56,227 @@ def _barcode_data_uri(value: str) -> str:
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
def _qr_placeholder_svg(size: int = 120) -> str:
|
||||
"""Simple QR-style grid when qrcode lib unavailable."""
|
||||
cell = max(size // 10, 8)
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">',
|
||||
f'<rect width="{size}" height="{size}" fill="#fff"/>',
|
||||
]
|
||||
for y in range(0, size, cell):
|
||||
for x in range(0, size, cell):
|
||||
if (x + y) % (cell * 2) == 0 or x < cell * 3 and y < cell * 3 or x > size - cell * 4 and y < cell * 3:
|
||||
parts.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="#111"/>')
|
||||
parts.append("</svg>")
|
||||
raw = "".join(parts).encode("utf-8")
|
||||
return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
|
||||
|
||||
|
||||
def _spec_text(spec: dict[str, Any]) -> dict[str, str]:
|
||||
text = spec.get("text") or {}
|
||||
if not isinstance(text, dict):
|
||||
text = {}
|
||||
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
|
||||
return {
|
||||
"product_name": str(text.get("product_name") or brand.get("product_name") or "FOODLINKK"),
|
||||
"tagline": str(text.get("tagline") or brand.get("tagline") or "Premium food solutions"),
|
||||
"subtitle": str(text.get("subtitle") or text.get("weight") or ""),
|
||||
"ingredients": str(text.get("ingredients") or ""),
|
||||
"best_before": str(text.get("best_before") or "Ten minste houdbaar tot: zie verpakking"),
|
||||
"origin": str(text.get("origin") or "Geproduceerd in NL"),
|
||||
}
|
||||
|
||||
|
||||
def _nutrition_rows(spec: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
text = spec.get("text") or {}
|
||||
rows = text.get("nutrition") if isinstance(text, dict) else None
|
||||
if isinstance(rows, list) and rows:
|
||||
out: list[tuple[str, str]] = []
|
||||
for row in rows[:12]:
|
||||
if isinstance(row, dict):
|
||||
out.append((str(row.get("k") or row.get("label") or ""), str(row.get("v") or row.get("value") or "")))
|
||||
return out
|
||||
return [
|
||||
("Energie", "450 kJ / 107 kcal"),
|
||||
("Vetten", "4.2 g"),
|
||||
("waarvan verzadigd", "1.1 g"),
|
||||
("Koolhydraten", "12 g"),
|
||||
("waarvan suikers", "2.8 g"),
|
||||
("Eiwitten", "6.5 g"),
|
||||
("Zout", "0.85 g"),
|
||||
]
|
||||
|
||||
|
||||
def _draw_bleed(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], bleed_mm: float) -> None:
|
||||
if bleed_mm <= 0:
|
||||
return
|
||||
b = _mm(bleed_mm)
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(x - b, y - b),
|
||||
size=(w + 2 * b, h + 2 * b),
|
||||
fill="none",
|
||||
stroke=brand["bleed"],
|
||||
stroke_dasharray="6,4",
|
||||
stroke_width=1.0,
|
||||
stroke_opacity=0.7,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _draw_logo_block(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], txt: dict[str, str]) -> None:
|
||||
dwg.add(
|
||||
dwg.rect(insert=(x, y), size=(w, h), rx=10, ry=10, fill=brand["panel"], stroke=brand["secondary"], stroke_width=2)
|
||||
)
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
txt["product_name"][:42],
|
||||
insert=(x + 14, y + h * 0.42),
|
||||
fill=brand["text"],
|
||||
font_size=min(22, max(12, w * 0.045)),
|
||||
font_family="Arial, Helvetica, sans-serif",
|
||||
font_weight="bold",
|
||||
)
|
||||
)
|
||||
if txt["tagline"]:
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
txt["tagline"][:60],
|
||||
insert=(x + 14, y + h * 0.62),
|
||||
fill=brand["primary"],
|
||||
font_size=min(14, max(9, w * 0.028)),
|
||||
font_family="Arial, Helvetica, sans-serif",
|
||||
)
|
||||
)
|
||||
if txt["subtitle"]:
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
txt["subtitle"][:24],
|
||||
insert=(x + 14, y + h * 0.82),
|
||||
fill=brand["muted"],
|
||||
font_size=11,
|
||||
font_family="Arial, Helvetica, sans-serif",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _draw_nutrition_panel(
|
||||
dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], rows: list[tuple[str, str]]
|
||||
) -> None:
|
||||
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#ffffff", stroke=brand["text"], stroke_width=1.2, rx=4))
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
"Voedingswaarden per 100g",
|
||||
insert=(x + 8, y + 16),
|
||||
fill="#111827",
|
||||
font_size=11,
|
||||
font_weight="bold",
|
||||
font_family="Arial, sans-serif",
|
||||
)
|
||||
)
|
||||
line_y = y + 26
|
||||
for label, value in rows:
|
||||
dwg.add(dwg.text(label[:28], insert=(x + 8, line_y), fill="#374151", font_size=9, font_family="Arial, sans-serif"))
|
||||
dwg.add(dwg.text(value[:16], insert=(x + w - 8, line_y), fill="#111827", font_size=9, font_family="Arial, sans-serif", text_anchor="end"))
|
||||
line_y += 13
|
||||
if line_y > y + h - 6:
|
||||
break
|
||||
|
||||
|
||||
def _draw_ingredients(dwg: svgwrite.Drawing, x: float, y: float, w: float, text: str, brand: dict[str, str]) -> None:
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
"Ingrediënten:",
|
||||
insert=(x, y),
|
||||
fill=brand["text"],
|
||||
font_size=10,
|
||||
font_weight="bold",
|
||||
font_family="Arial, sans-serif",
|
||||
)
|
||||
)
|
||||
chunk = text[:220] or "Ingrediënten volgens recept — vul aan in Packaging Studio."
|
||||
words, line, line_y, line_h = chunk.split(), "", y + 14, 12
|
||||
for word in words:
|
||||
test = (line + " " + word).strip()
|
||||
if len(test) > 42:
|
||||
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
|
||||
line, line_y = word, line_y + line_h
|
||||
else:
|
||||
line = test
|
||||
if line:
|
||||
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
|
||||
|
||||
|
||||
def _draw_halal_badge(dwg: svgwrite.Drawing, x: float, y: float, brand: dict[str, str]) -> None:
|
||||
r = 28
|
||||
dwg.add(dwg.circle(center=(x + r, y + r), r=r, fill=brand["halal"], stroke="#ffffff", stroke_width=2))
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
"HALAL",
|
||||
insert=(x + r, y + r + 5),
|
||||
fill="#052e16",
|
||||
font_size=11,
|
||||
font_weight="bold",
|
||||
font_family="Arial, sans-serif",
|
||||
text_anchor="middle",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _draw_window(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float) -> None:
|
||||
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#bae6fd", fill_opacity=0.35, stroke="#38bdf8", stroke_width=1.5, rx=6))
|
||||
dwg.add(dwg.text("WINDOW", insert=(x + w / 2, y + h / 2 + 4), fill="#0c4a6e", font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
|
||||
|
||||
|
||||
def _draw_dimensions(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, spec: dict[str, Any], brand: dict[str, str]) -> None:
|
||||
label = f"{spec.get('width_mm')} × {spec.get('height_mm')} × {spec.get('depth_mm')} mm"
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
label,
|
||||
insert=(x + w / 2, y + h + 18),
|
||||
fill=brand["muted"],
|
||||
font_size=10,
|
||||
text_anchor="middle",
|
||||
font_family="Arial, sans-serif",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _canvas_size(ptype: str, width_mm: float, height_mm: float, depth_mm: float) -> tuple[float, float]:
|
||||
if ptype == "folding_box":
|
||||
return _mm((width_mm * 2) + (depth_mm * 2) + 20), _mm(height_mm + depth_mm + 20)
|
||||
if ptype == "wrap":
|
||||
return _mm(width_mm + 20), _mm(height_mm + 20)
|
||||
if ptype == "round_label":
|
||||
d = max(min(width_mm, height_mm), 20)
|
||||
return _mm(d + 20), _mm(d + 20)
|
||||
if ptype == "sleeve":
|
||||
return _mm((width_mm * 2) + depth_mm + 20), _mm(height_mm + 20)
|
||||
if ptype == "pouch":
|
||||
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
|
||||
if ptype == "tray":
|
||||
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
|
||||
raise ValueError(f"Unsupported packaging type: {ptype}")
|
||||
|
||||
|
||||
def generate_packaging(spec: dict[str, Any]) -> str:
|
||||
"""Create an SVG packaging design based on a simple spec."""
|
||||
"""Create an SVG packaging design from a rich spec."""
|
||||
ptype = (spec.get("type") or "folding_box").strip().lower()
|
||||
if ptype not in PACKAGING_TYPES:
|
||||
raise ValueError(f"Unsupported packaging type: {ptype}")
|
||||
|
||||
width_mm = float(spec.get("width_mm", 120))
|
||||
height_mm = float(spec.get("height_mm", 80))
|
||||
depth_mm = float(spec.get("depth_mm", 40))
|
||||
bleed_mm = float(spec.get("bleed_mm", 3))
|
||||
elements = spec.get("elements", {})
|
||||
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
|
||||
txt = _spec_text(spec)
|
||||
|
||||
if ptype == "folding_box":
|
||||
canvas_w = _mm((width_mm * 2) + (depth_mm * 2) + 20)
|
||||
canvas_h = _mm(height_mm + depth_mm + 20)
|
||||
elif ptype == "wrap":
|
||||
canvas_w = _mm(width_mm + 20)
|
||||
canvas_h = _mm(height_mm + 20)
|
||||
elif ptype == "round_label":
|
||||
diameter = max(min(width_mm, height_mm), 20)
|
||||
canvas_w = _mm(diameter + 20)
|
||||
canvas_h = _mm(diameter + 20)
|
||||
else:
|
||||
raise ValueError(f"Unsupported packaging type: {ptype}")
|
||||
|
||||
canvas_w, canvas_h = _canvas_size(ptype, width_mm, height_mm, depth_mm)
|
||||
dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
|
||||
dwg.viewbox(0, 0, canvas_w, canvas_h)
|
||||
|
||||
# Background and frame
|
||||
dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"]))
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
@@ -85,141 +292,114 @@ def generate_packaging(spec: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
margin = 24
|
||||
body_w = body_h = x0 = y0 = 0.0
|
||||
|
||||
if ptype == "folding_box":
|
||||
body_w = _mm(width_mm)
|
||||
body_h = _mm(height_mm)
|
||||
depth_w = _mm(depth_mm)
|
||||
x0 = margin
|
||||
y0 = margin
|
||||
x0, y0 = margin, margin
|
||||
panels = [depth_w, body_w, depth_w, body_w]
|
||||
x = x0
|
||||
for idx, panel_w in enumerate(panels):
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(x, y0),
|
||||
size=(panel_w, body_h),
|
||||
fill="none",
|
||||
stroke=brand["primary"] if idx % 2 else brand["secondary"],
|
||||
stroke_opacity=0.45,
|
||||
stroke_width=1.6,
|
||||
)
|
||||
)
|
||||
fill = brand["primary"] if idx % 2 else brand["secondary"]
|
||||
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=fill, stroke_opacity=0.45, stroke_width=1.6))
|
||||
x += panel_w
|
||||
|
||||
if _elements_enabled(elements, "fold_lines"):
|
||||
x = x0 + panels[0]
|
||||
for panel_w in panels[1:]:
|
||||
dwg.add(
|
||||
dwg.line(
|
||||
start=(x, y0),
|
||||
end=(x, y0 + body_h),
|
||||
stroke=brand["fold_line"],
|
||||
stroke_dasharray="8,6",
|
||||
stroke_width=1.2,
|
||||
)
|
||||
)
|
||||
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
|
||||
x += panel_w
|
||||
|
||||
if _elements_enabled(elements, "cut_lines"):
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(x0, y0),
|
||||
size=(sum(panels), body_h),
|
||||
fill="none",
|
||||
stroke=brand["cut_line"],
|
||||
stroke_dasharray="5,4",
|
||||
stroke_width=1.1,
|
||||
)
|
||||
)
|
||||
dwg.add(dwg.rect(insert=(x0, y0), size=(sum(panels), body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="5,4", stroke_width=1.1))
|
||||
if _elements_enabled(elements, "glue_tabs"):
|
||||
tab_w, tab_h = _mm(12), _mm(8)
|
||||
dwg.add(dwg.rect(insert=(x0 - tab_w, y0 + body_h * 0.4), size=(tab_w, tab_h), fill=brand["accent"], fill_opacity=0.35, stroke=brand["accent"]))
|
||||
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.1)
|
||||
logo_y = y0 + (_mm(height_mm) * 0.12)
|
||||
logo_w = _mm(width_mm) * 0.8
|
||||
logo_h = _mm(height_mm) * 0.38
|
||||
|
||||
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.12)
|
||||
logo_y = y0 + (_mm(height_mm) * 0.16)
|
||||
logo_w = _mm(width_mm) * 0.76
|
||||
logo_h = _mm(height_mm) * 0.42
|
||||
elif ptype == "wrap":
|
||||
body_w, body_h = _mm(width_mm), _mm(height_mm)
|
||||
x0, y0 = margin, margin
|
||||
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.2))
|
||||
if _elements_enabled(elements, "fold_lines"):
|
||||
dwg.add(dwg.line(start=(x0 + body_w / 2, y0), end=(x0 + body_w / 2, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
|
||||
if _elements_enabled(elements, "cut_lines"):
|
||||
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="6,4", stroke_width=1.1))
|
||||
logo_x, logo_y = x0 + body_w * 0.1, y0 + body_h * 0.12
|
||||
logo_w, logo_h = body_w * 0.8, body_h * 0.35
|
||||
|
||||
elif ptype == "sleeve":
|
||||
body_w, body_h = _mm(width_mm), _mm(height_mm)
|
||||
depth_w = _mm(depth_mm)
|
||||
x0, y0 = margin, margin
|
||||
panels = [body_w, depth_w, body_w]
|
||||
x = x0
|
||||
for panel_w in panels:
|
||||
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=brand["primary"], stroke_width=1.8))
|
||||
x += panel_w
|
||||
if _elements_enabled(elements, "fold_lines"):
|
||||
x = x0 + panels[0]
|
||||
for panel_w in panels[1:]:
|
||||
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
|
||||
x += panel_w
|
||||
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.15
|
||||
logo_w, logo_h = body_w * 0.76, body_h * 0.4
|
||||
|
||||
elif ptype == "pouch":
|
||||
body_w = _mm(width_mm)
|
||||
body_h = _mm(height_mm)
|
||||
x0 = margin
|
||||
y0 = margin
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(x0, y0),
|
||||
size=(body_w, body_h),
|
||||
fill="none",
|
||||
stroke=brand["primary"],
|
||||
stroke_width=2.2,
|
||||
)
|
||||
)
|
||||
if _elements_enabled(elements, "cut_lines"):
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(x0, y0),
|
||||
size=(body_w, body_h),
|
||||
fill="none",
|
||||
stroke=brand["cut_line"],
|
||||
stroke_dasharray="6,4",
|
||||
stroke_width=1.1,
|
||||
)
|
||||
)
|
||||
logo_x = x0 + (body_w * 0.14)
|
||||
logo_y = y0 + (body_h * 0.14)
|
||||
logo_w = body_w * 0.72
|
||||
logo_h = body_h * 0.36
|
||||
else:
|
||||
seal_h = _mm(max(depth_mm, 15))
|
||||
x0, y0 = margin, margin + seal_h
|
||||
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2))
|
||||
dwg.add(dwg.rect(insert=(x0, y0 - seal_h), size=(body_w, seal_h), fill=brand["secondary"], fill_opacity=0.2, stroke=brand["secondary"]))
|
||||
dwg.add(dwg.text("SEAL", insert=(x0 + body_w / 2, y0 - seal_h / 2 + 4), fill=brand["text"], font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
|
||||
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.18
|
||||
logo_w, logo_h = body_w * 0.76, body_h * 0.42
|
||||
|
||||
elif ptype == "tray":
|
||||
body_w, body_h = _mm(width_mm), _mm(height_mm)
|
||||
lip = _mm(max(depth_mm, 8))
|
||||
x0, y0 = margin, margin
|
||||
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.4))
|
||||
dwg.add(dwg.rect(insert=(x0 + lip, y0 + lip), size=(body_w - 2 * lip, body_h - 2 * lip), fill="none", stroke=brand["fold_line"], stroke_dasharray="5,4", stroke_width=1))
|
||||
logo_x, logo_y = x0 + lip + 8, y0 + lip + 8
|
||||
logo_w, logo_h = body_w - 2 * lip - 16, (body_h - 2 * lip) * 0.45
|
||||
|
||||
else: # round_label
|
||||
diameter = min(canvas_w, canvas_h) - (margin * 2)
|
||||
cx = canvas_w / 2
|
||||
cy = canvas_h / 2
|
||||
dwg.add(
|
||||
dwg.circle(
|
||||
center=(cx, cy),
|
||||
r=diameter / 2,
|
||||
fill="none",
|
||||
stroke=brand["primary"],
|
||||
stroke_width=2.4,
|
||||
)
|
||||
)
|
||||
cx, cy = canvas_w / 2, canvas_h / 2
|
||||
dwg.add(dwg.circle(center=(cx, cy), r=diameter / 2, fill="none", stroke=brand["primary"], stroke_width=2.4))
|
||||
if _elements_enabled(elements, "cut_lines"):
|
||||
dwg.add(
|
||||
dwg.circle(
|
||||
center=(cx, cy),
|
||||
r=(diameter / 2) - 4,
|
||||
fill="none",
|
||||
stroke=brand["cut_line"],
|
||||
stroke_dasharray="4,4",
|
||||
stroke_width=1.0,
|
||||
)
|
||||
)
|
||||
logo_w = diameter * 0.64
|
||||
logo_h = diameter * 0.22
|
||||
logo_x = cx - (logo_w / 2)
|
||||
logo_y = cy - (logo_h / 2) - 8
|
||||
body_w = diameter
|
||||
body_h = diameter
|
||||
x0 = cx - (diameter / 2)
|
||||
y0 = cy - (diameter / 2)
|
||||
dwg.add(dwg.circle(center=(cx, cy), r=(diameter / 2) - 4, fill="none", stroke=brand["cut_line"], stroke_dasharray="4,4", stroke_width=1.0))
|
||||
logo_w, logo_h = diameter * 0.64, diameter * 0.22
|
||||
logo_x, logo_y = cx - (logo_w / 2), cy - (logo_h / 2) - 8
|
||||
body_w = body_h = diameter
|
||||
x0, y0 = cx - (diameter / 2), cy - (diameter / 2)
|
||||
|
||||
if _elements_enabled(elements, "bleed"):
|
||||
_draw_bleed(dwg, x0, y0, body_w if ptype != "folding_box" else sum([_mm(depth_mm), _mm(width_mm), _mm(depth_mm), _mm(width_mm)]), body_h, brand, bleed_mm)
|
||||
|
||||
if _elements_enabled(elements, "logo_area"):
|
||||
dwg.add(
|
||||
dwg.rect(
|
||||
insert=(logo_x, logo_y),
|
||||
size=(logo_w, logo_h),
|
||||
rx=8,
|
||||
ry=8,
|
||||
fill="none",
|
||||
stroke=brand["secondary"],
|
||||
stroke_width=2,
|
||||
)
|
||||
)
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
"FOODLINKK",
|
||||
insert=(logo_x + 12, logo_y + (logo_h / 2) + 5),
|
||||
fill=brand["text"],
|
||||
font_size=18,
|
||||
font_family="Arial, sans-serif",
|
||||
font_weight="bold",
|
||||
)
|
||||
)
|
||||
_draw_logo_block(dwg, logo_x, logo_y, logo_w, logo_h, brand, txt)
|
||||
|
||||
if _elements_enabled(elements, "window"):
|
||||
wx = x0 + body_w * 0.55 if ptype != "round_label" else logo_x + logo_w * 0.1
|
||||
wy = y0 + body_h * 0.55 if ptype != "round_label" else logo_y + logo_h + 8
|
||||
_draw_window(dwg, wx, wy, max(60, body_w * 0.32), max(40, body_h * 0.22))
|
||||
|
||||
if _elements_enabled(elements, "halal_badge"):
|
||||
_draw_halal_badge(dwg, x0 + 8, y0 + 8, brand)
|
||||
|
||||
if _elements_enabled(elements, "nutrition_panel"):
|
||||
nx = x0 + 8
|
||||
ny = y0 + body_h - min(120, body_h * 0.45)
|
||||
_draw_nutrition_panel(dwg, nx, ny, min(150, body_w * 0.42), min(110, body_h * 0.4), brand, _nutrition_rows(spec))
|
||||
|
||||
if _elements_enabled(elements, "ingredients") and txt["ingredients"]:
|
||||
_draw_ingredients(dwg, x0 + 8, y0 + body_h - 28, body_w - 16, txt["ingredients"], brand)
|
||||
|
||||
if _elements_enabled(elements, "barcode"):
|
||||
barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE))
|
||||
@@ -230,4 +410,23 @@ def generate_packaging(spec: dict[str, Any]) -> str:
|
||||
dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff"))
|
||||
dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h)))
|
||||
|
||||
if _elements_enabled(elements, "qr_code"):
|
||||
qr_uri = _qr_placeholder_svg(100)
|
||||
qr_s = min(90, body_w * 0.22)
|
||||
dwg.add(dwg.image(href=qr_uri, insert=(x0 + 10, y0 + body_h - qr_s - 10), size=(qr_s, qr_s)))
|
||||
|
||||
if _elements_enabled(elements, "dimensions"):
|
||||
_draw_dimensions(dwg, x0, y0, body_w, body_h, spec, brand)
|
||||
|
||||
design_name = spec.get("design_name") or txt["product_name"]
|
||||
dwg.add(
|
||||
dwg.text(
|
||||
f"Foodlinkk Packaging Studio · {design_name}"[:80],
|
||||
insert=(12, canvas_h - 10),
|
||||
fill=brand["muted"],
|
||||
font_size=9,
|
||||
font_family="Arial, sans-serif",
|
||||
)
|
||||
)
|
||||
|
||||
return dwg.tostring()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Packaging generation API routes."""
|
||||
"""Packaging generation API routes — persisted in PostgreSQL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -9,58 +10,229 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes
|
||||
from app.packaging.generator import FOODLINKK_BRAND, generate_packaging
|
||||
from app.packaging.generator import FOODLINKK_BRAND, PACKAGING_TYPES, generate_packaging
|
||||
|
||||
router = APIRouter(prefix="/packaging", tags=["packaging"])
|
||||
|
||||
_PROJECTS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
class PackagingSpec(BaseModel):
|
||||
type: Literal["folding_box", "wrap", "round_label"]
|
||||
type: Literal["folding_box", "wrap", "round_label", "sleeve", "pouch", "tray"] = "folding_box"
|
||||
width_mm: float = Field(default=120, gt=0, le=4000)
|
||||
height_mm: float = Field(default=80, gt=0, le=4000)
|
||||
depth_mm: float = Field(default=40, ge=0, le=4000)
|
||||
bleed_mm: float = Field(default=3, ge=0, le=20)
|
||||
elements: dict[str, bool] = Field(default_factory=dict)
|
||||
brand: dict[str, str] = Field(default_factory=dict)
|
||||
text: dict[str, Any] = Field(default_factory=dict)
|
||||
barcode_value: str | None = Field(default=None, max_length=64)
|
||||
qr_value: str | None = Field(default=None, max_length=512)
|
||||
design_name: str | None = Field(default=None, max_length=120)
|
||||
project_id: int | None = None
|
||||
project_name: str | None = None
|
||||
client_id: int | None = None
|
||||
created_by: str = "ceo"
|
||||
|
||||
|
||||
class PackagingUpdate(BaseModel):
|
||||
spec: PackagingSpec
|
||||
|
||||
|
||||
class CopyToProjectBody(BaseModel):
|
||||
target_project_id: int = Field(..., gt=0)
|
||||
created_by: str = "ceo"
|
||||
|
||||
|
||||
class PackagingEmailBody(BaseModel):
|
||||
to: list[str] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1, max_length=255)
|
||||
body: str = ""
|
||||
cc: list[str] = Field(default_factory=list)
|
||||
attach_formats: list[str] = Field(default_factory=lambda: ["pdf"])
|
||||
client_id: int | None = None
|
||||
|
||||
|
||||
def _normalize_spec(spec_data: dict[str, Any]) -> dict[str, Any]:
|
||||
if not spec_data.get("brand"):
|
||||
spec_data["brand"] = dict(FOODLINKK_BRAND)
|
||||
if not spec_data.get("elements"):
|
||||
spec_data["elements"] = {
|
||||
"barcode": True,
|
||||
"logo_area": True,
|
||||
"fold_lines": True,
|
||||
"cut_lines": True,
|
||||
"nutrition_panel": False,
|
||||
"ingredients": False,
|
||||
"halal_badge": False,
|
||||
"window": False,
|
||||
"qr_code": False,
|
||||
"glue_tabs": False,
|
||||
"bleed": True,
|
||||
"dimensions": True,
|
||||
}
|
||||
return spec_data
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(row)
|
||||
spec = out.get("spec") or {}
|
||||
if isinstance(spec, str):
|
||||
try:
|
||||
spec = json.loads(spec)
|
||||
except Exception:
|
||||
spec = {}
|
||||
out["spec"] = spec
|
||||
for key in ("created_at",):
|
||||
if out.get(key) is not None and hasattr(out[key], "isoformat"):
|
||||
out[key] = out[key].isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def _get_project(project_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT po.*, cp.name AS project_name, cp.nas_path, c.name AS client_name, c.id AS client_id
|
||||
FROM packaging_outputs po
|
||||
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
|
||||
LEFT JOIN clients c ON c.id = cp.client_id
|
||||
WHERE po.id = %s
|
||||
""",
|
||||
(project_id,),
|
||||
)
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
|
||||
|
||||
def _link_asset(cockpit_project_id: int, packaging_id: str, spec_data: dict[str, Any], created_by: str, file_path: str | None = None) -> None:
|
||||
title = spec_data.get("design_name") or spec_data.get("text", {}).get("product_name") or f"Packaging {spec_data.get('type')}"
|
||||
existing = fetch_one(
|
||||
"SELECT id FROM project_assets WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s LIMIT 1",
|
||||
(cockpit_project_id, packaging_id),
|
||||
)
|
||||
payload = json.dumps({"packaging_id": packaging_id, "spec": spec_data})
|
||||
if existing:
|
||||
execute(
|
||||
"""
|
||||
UPDATE project_assets SET title = %s, payload = %s::jsonb, file_path = COALESCE(%s, file_path)
|
||||
WHERE id = %s
|
||||
""",
|
||||
(title, payload, file_path, existing["id"]),
|
||||
)
|
||||
else:
|
||||
fetch_one(
|
||||
"""
|
||||
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
|
||||
VALUES (%s, 'packaging', %s, %s, %s, %s::jsonb, %s, 'packaging')
|
||||
RETURNING id
|
||||
""",
|
||||
(cockpit_project_id, packaging_id, title, file_path, payload, created_by),
|
||||
)
|
||||
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (cockpit_project_id,))
|
||||
|
||||
|
||||
def _resolve_cockpit_project(spec: PackagingSpec) -> int | None:
|
||||
cockpit_project_id = spec.project_id
|
||||
if spec.project_name and not cockpit_project_id:
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO cockpit_projects (name, client_id, description, created_by, project_type, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'packaging', NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(spec.project_name.strip(), spec.client_id, "Packaging project", spec.created_by),
|
||||
)
|
||||
cockpit_project_id = int(row["id"]) if row else None
|
||||
return cockpit_project_id
|
||||
|
||||
|
||||
def _persist_packaging(spec: PackagingSpec, packaging_id: str | None = None) -> dict[str, Any]:
|
||||
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
|
||||
svg = generate_packaging(spec_data)
|
||||
pid = packaging_id or uuid4().hex
|
||||
cockpit_project_id = _resolve_cockpit_project(spec)
|
||||
|
||||
existing = _get_project(pid) if packaging_id else None
|
||||
if existing:
|
||||
fetch_one(
|
||||
"""
|
||||
UPDATE packaging_outputs SET project_id = %s, spec = %s::jsonb, svg = %s, created_by = %s
|
||||
WHERE id = %s RETURNING id
|
||||
""",
|
||||
(cockpit_project_id, json.dumps(spec_data), svg, spec.created_by, pid),
|
||||
)
|
||||
else:
|
||||
fetch_one(
|
||||
"""
|
||||
INSERT INTO packaging_outputs (id, project_id, spec, svg, created_by, created_at)
|
||||
VALUES (%s, %s, %s::jsonb, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(pid, cockpit_project_id, json.dumps(spec_data), svg, spec.created_by),
|
||||
)
|
||||
|
||||
if cockpit_project_id:
|
||||
_link_asset(cockpit_project_id, pid, spec_data, spec.created_by)
|
||||
|
||||
return {
|
||||
"id": pid,
|
||||
"cockpit_project_id": cockpit_project_id,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"svg": svg,
|
||||
"spec": spec_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def packaging_types() -> dict[str, Any]:
|
||||
labels = {
|
||||
"folding_box": "Folding box (sluitdoos)",
|
||||
"wrap": "Wrap / banderole",
|
||||
"round_label": "Rond label",
|
||||
"sleeve": "Sleeve / huls",
|
||||
"pouch": "Pouch / zak",
|
||||
"tray": "Tray / schaal",
|
||||
}
|
||||
return {"items": [{"id": t, "label": labels.get(t, t)} for t in PACKAGING_TYPES]}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
def packaging_preview(spec: PackagingSpec) -> dict[str, Any]:
|
||||
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
|
||||
return {"svg": generate_packaging(spec_data), "spec": spec_data}
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def packaging_generate(spec: PackagingSpec) -> dict[str, Any]:
|
||||
spec_data = spec.model_dump()
|
||||
if not spec_data["brand"]:
|
||||
spec_data["brand"] = dict(FOODLINKK_BRAND)
|
||||
svg = generate_packaging(spec_data)
|
||||
project_id = uuid4().hex
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
_PROJECTS[project_id] = {
|
||||
"id": project_id,
|
||||
"created_at": now,
|
||||
"spec": spec_data,
|
||||
"svg": svg,
|
||||
}
|
||||
return {"id": project_id, "created_at": now, "svg": svg, "spec": spec_data}
|
||||
return _persist_packaging(spec)
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]:
|
||||
items = sorted(_PROJECTS.values(), key=lambda x: x["created_at"], reverse=True)[:limit]
|
||||
return {
|
||||
"items": [{"id": p["id"], "created_at": p["created_at"], "spec": p["spec"]} for p in items],
|
||||
"count": len(items),
|
||||
}
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT po.id, po.project_id AS cockpit_project_id, po.spec, po.created_by, po.created_at,
|
||||
cp.name AS project_name, cp.nas_path, c.name AS client_name
|
||||
FROM packaging_outputs po
|
||||
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
|
||||
LEFT JOIN clients c ON c.id = cp.client_id
|
||||
ORDER BY po.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return {"items": [_serialize_row(dict(r)) for r in rows], "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/download/{project_id}")
|
||||
def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")):
|
||||
project = _PROJECTS.get(project_id)
|
||||
project = _get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Packaging project not found")
|
||||
|
||||
svg = project["svg"]
|
||||
filename = f"foodlinkk-packaging-{project_id[:8]}.{format}"
|
||||
slug = (project.get("spec") or {}).get("design_name") or project_id[:8]
|
||||
safe_slug = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower())[:40]
|
||||
filename = f"foodlinkk-{safe_slug}.{format}"
|
||||
if format == "svg":
|
||||
return Response(
|
||||
content=svg.encode("utf-8"),
|
||||
@@ -78,3 +250,57 @@ def packaging_download(project_id: str, format: str = Query(default="svg", patte
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{packaging_id}")
|
||||
def packaging_get(packaging_id: str) -> dict[str, Any]:
|
||||
row = _get_project(packaging_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Packaging design not found")
|
||||
return {"item": row}
|
||||
|
||||
|
||||
@router.patch("/{packaging_id}")
|
||||
def packaging_update(packaging_id: str, body: PackagingUpdate) -> dict[str, Any]:
|
||||
if not _get_project(packaging_id):
|
||||
raise HTTPException(status_code=404, detail="Packaging design not found")
|
||||
existing = _get_project(packaging_id) or {}
|
||||
spec = body.spec
|
||||
if not spec.project_id and existing.get("project_id"):
|
||||
spec.project_id = int(existing["project_id"])
|
||||
result = _persist_packaging(spec, packaging_id=packaging_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{packaging_id}/duplicate")
|
||||
def packaging_duplicate(packaging_id: str, created_by: str = Query(default="ceo")) -> dict[str, Any]:
|
||||
row = _get_project(packaging_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Packaging design not found")
|
||||
spec = dict(row.get("spec") or {})
|
||||
spec["design_name"] = (spec.get("design_name") or spec.get("text", {}).get("product_name") or "Design") + " (kopie)"
|
||||
payload = PackagingSpec(**{**spec, "project_id": int(row["project_id"]) if row.get("project_id") else None, "created_by": created_by})
|
||||
return _persist_packaging(payload)
|
||||
|
||||
|
||||
@router.post("/{packaging_id}/copy-to-project")
|
||||
def packaging_copy_to_project(packaging_id: str, body: CopyToProjectBody) -> dict[str, Any]:
|
||||
row = _get_project(packaging_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Packaging design not found")
|
||||
target = fetch_one("SELECT id, name FROM cockpit_projects WHERE id = %s", (body.target_project_id,))
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target project not found")
|
||||
spec = dict(row.get("spec") or {})
|
||||
spec["design_name"] = (spec.get("design_name") or "Design") + f" → {target['name']}"
|
||||
payload = PackagingSpec(**{**spec, "project_id": body.target_project_id, "created_by": body.created_by})
|
||||
return _persist_packaging(payload)
|
||||
|
||||
|
||||
@router.delete("/{packaging_id}")
|
||||
def packaging_delete(packaging_id: str) -> dict[str, Any]:
|
||||
if not _get_project(packaging_id):
|
||||
raise HTTPException(status_code=404, detail="Packaging design not found")
|
||||
execute("DELETE FROM project_assets WHERE asset_type = 'packaging' AND ref_id = %s", (packaging_id,))
|
||||
execute("DELETE FROM packaging_outputs WHERE id = %s", (packaging_id,))
|
||||
return {"ok": True, "id": packaging_id}
|
||||
|
||||
@@ -177,6 +177,25 @@ def run_research() -> dict[str, Any]:
|
||||
title="Full research cycle completed",
|
||||
metadata={"briefs": 3, "snapshots": len(snapshot_ids)},
|
||||
)
|
||||
|
||||
from app.connectors.project_assets import register_agent_output
|
||||
|
||||
for brief, domain in [
|
||||
(crm_brief, "crm"),
|
||||
(retail_brief, "retail"),
|
||||
(social_brief, "social"),
|
||||
]:
|
||||
try:
|
||||
register_agent_output(
|
||||
asset_type="research_brief",
|
||||
title=brief.get("title") or f"Research {domain}",
|
||||
ref_id=str(brief.get("id")),
|
||||
payload={"domain": domain, "summary": brief.get("summary"), "brief_id": brief.get("id")},
|
||||
source_agent="research",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"snapshots": snapshot_ids,
|
||||
"briefs": [crm_brief["id"], retail_brief["id"], social_brief["id"]],
|
||||
|
||||
+98
-1
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import execute_returning, fetch_all, fetch_one
|
||||
from app.db import execute, execute_returning, fetch_all, fetch_one
|
||||
from app.middleware import log_agent_event
|
||||
from app import retail_scrapers
|
||||
from app import retail_enrichment
|
||||
@@ -35,6 +35,49 @@ class CrmLinkIn(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SupermarketCreateIn(BaseModel):
|
||||
name: str
|
||||
chain: str
|
||||
address: str
|
||||
postcode: str = "0000AA"
|
||||
city: str
|
||||
province: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
employee_count: Optional[int] = None
|
||||
store_type: Optional[str] = None
|
||||
partnership_status: str = "none"
|
||||
halal_certified: bool = False
|
||||
has_halal_section: bool = False
|
||||
halal_certifier: Optional[str] = None
|
||||
data_source: str = "manual"
|
||||
|
||||
|
||||
class SupermarketPatchIn(BaseModel):
|
||||
name: Optional[str] = None
|
||||
chain: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postcode: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
province: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
employee_count: Optional[int] = None
|
||||
store_type: Optional[str] = None
|
||||
size_m2: Optional[int] = None
|
||||
partnership_status: Optional[str] = None
|
||||
halal_certified: Optional[bool] = None
|
||||
has_halal_section: Optional[bool] = None
|
||||
halal_certifier: Optional[str] = None
|
||||
halal_certificate_number: Optional[str] = None
|
||||
organic_section: Optional[bool] = None
|
||||
alcohol_section: Optional[bool] = None
|
||||
|
||||
|
||||
STORE_SELECT = """
|
||||
SELECT s.*,
|
||||
a.population AS area_population,
|
||||
@@ -360,6 +403,60 @@ def get_supermarket(store_id: int) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/supermarkets")
|
||||
def create_supermarket(payload: SupermarketCreateIn) -> dict[str, Any]:
|
||||
row = execute_returning(
|
||||
"""INSERT INTO supermarkets (
|
||||
name, chain, address, postcode, city, province, phone, email, website,
|
||||
manager_name, employee_count, store_type, partnership_status,
|
||||
halal_certified, has_halal_section, halal_certifier, data_source, last_updated
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
|
||||
RETURNING id""",
|
||||
(
|
||||
payload.name.strip(), payload.chain.strip(), payload.address.strip(),
|
||||
payload.postcode.strip().upper(), payload.city.strip(), payload.province,
|
||||
payload.phone, payload.email, payload.website, payload.manager_name,
|
||||
payload.employee_count, payload.store_type, payload.partnership_status,
|
||||
payload.halal_certified, payload.has_halal_section, payload.halal_certifier,
|
||||
payload.data_source,
|
||||
),
|
||||
)
|
||||
store_id = int(row["id"])
|
||||
log_agent_event(
|
||||
agent_name="retail_crm",
|
||||
event_type="create",
|
||||
title=f"Handmatig filiaal toegevoegd: {payload.name} ({payload.chain})",
|
||||
)
|
||||
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
|
||||
|
||||
|
||||
@router.patch("/supermarkets/{store_id}")
|
||||
def patch_supermarket(store_id: int, payload: SupermarketPatchIn) -> dict[str, Any]:
|
||||
existing = fetch_one("SELECT id FROM supermarkets WHERE id = %s", (store_id,))
|
||||
if not existing:
|
||||
raise HTTPException(404, "Supermarket not found")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if not data:
|
||||
raise HTTPException(400, "Geen velden om bij te werken")
|
||||
allowed = set(SupermarketPatchIn.model_fields.keys())
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
for key, val in data.items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
sets.append(f"{key} = %s")
|
||||
params.append(val)
|
||||
sets.append("last_updated = NOW()")
|
||||
params.append(store_id)
|
||||
execute(f"UPDATE supermarkets SET {', '.join(sets)} WHERE id = %s", tuple(params))
|
||||
log_agent_event(
|
||||
agent_name="retail_crm",
|
||||
event_type="update",
|
||||
title=f"Supermarkt #{store_id} data aangevuld",
|
||||
)
|
||||
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
|
||||
|
||||
|
||||
@router.get("/scrape/chains")
|
||||
def list_scrape_chains() -> dict[str, Any]:
|
||||
return {"chains": retail_scrapers.list_chains()}
|
||||
|
||||
@@ -94,3 +94,37 @@ def list_crm_options() -> dict[str, Any]:
|
||||
WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC LIMIT 200"""
|
||||
)
|
||||
return {"clients": [dict(c) for c in clients], "deals": [dict(d) for d in deals]}
|
||||
|
||||
|
||||
def get_client_retail_links(client_id: int) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
|
||||
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
|
||||
l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
|
||||
FROM client_supermarket_links l
|
||||
JOIN supermarkets s ON s.id = l.supermarket_id
|
||||
WHERE l.client_id = %s
|
||||
ORDER BY s.chain, s.city, s.name
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
direct = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
|
||||
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
|
||||
'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
|
||||
FROM supermarkets s
|
||||
WHERE s.client_id = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM client_supermarket_links l
|
||||
WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
|
||||
)
|
||||
ORDER BY s.chain, s.city, s.name
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
merged: dict[int, dict[str, Any]] = {}
|
||||
for row in list(rows) + list(direct):
|
||||
merged[int(row["id"])] = dict(row)
|
||||
return list(merged.values())
|
||||
|
||||
Reference in New Issue
Block a user