From 6a958a8d7d0691d7251e16130c178d4c7d957dbb Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 19 Apr 2026 15:41:29 +0300 Subject: [PATCH] update: Add Global Search on Header, Add Global Audit log for all actions. --- backend/builder/router.py | 26 +- backend/crm/customers_router.py | 99 ++- backend/crm/orders_router.py | 40 +- backend/crm/quotations_router.py | 26 +- backend/crm/router.py | 32 +- backend/devices/router.py | 48 +- backend/firmware/router.py | 22 +- backend/main.py | 2 + backend/melodies/router.py | 44 +- backend/notes/router.py | 20 +- backend/search/__init__.py | 0 backend/search/router.py | 163 ++++ backend/shared/audit.py | 11 +- backend/tickets/router.py | 22 +- backend/users/router.py | 40 +- docker-compose.yml | 36 +- .../src/assets/side-menu-icons/inventory.svg | 22 + frontend/src/components/layout/Header.jsx | 14 +- frontend/src/components/layout/Sidebar.jsx | 116 ++- frontend/src/components/ui/HeaderSearch.jsx | 238 +++++- .../pages/bellcloud/devices/DeviceDetail.jsx | 1 + .../bellcloud/devices/tabs/ManageTab.jsx | 182 ++--- .../bellcloud/devices/tabs/OverviewTab.jsx | 204 ++++- .../manufacturing/ProvisioningWizard.jsx | 24 +- frontend/src/pages/settings/LogViewerPage.jsx | 754 ++++++++++++++++++ frontend/src/router/index.jsx | 3 +- frontend/src/styles/components.css | 164 ++++ 27 files changed, 2086 insertions(+), 267 deletions(-) create mode 100644 backend/search/__init__.py create mode 100644 backend/search/router.py create mode 100644 frontend/src/assets/side-menu-icons/inventory.svg create mode 100644 frontend/src/pages/settings/LogViewerPage.jsx diff --git a/backend/builder/router.py b/backend/builder/router.py index abc1e9e..7769083 100644 --- a/backend/builder/router.py +++ b/backend/builder/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse, PlainTextResponse +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from builder.models import ( @@ -9,6 +10,8 @@ from builder.models import ( BuiltMelodyListResponse, ) from builder import service +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/builder/melodies", tags=["builder"]) @@ -54,8 +57,12 @@ async def get_built_melody( async def create_built_melody( body: BuiltMelodyCreate, _user: TokenPayload = Depends(require_permission("melodies", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.create_built_melody(body) + melody = await service.create_built_melody(body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "archetype", + str(melody.id), melody.name or str(melody.id)) + return melody @router.put("/{melody_id}", response_model=BuiltMelodyInDB) @@ -63,16 +70,31 @@ async def update_built_melody( melody_id: str, body: BuiltMelodyUpdate, _user: TokenPayload = Depends(require_permission("melodies", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.update_built_melody(melody_id, body) + old = await service.get_built_melody(melody_id) + melody = await service.update_built_melody(melody_id, body) + _SKIP = {"updated_at", "id", "steps", "builtin_code"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(melody, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(melody, k, None) + } + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "archetype", + melody_id, melody.name or melody_id, changes=changes or None) + return melody @router.delete("/{melody_id}", status_code=204) async def delete_built_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "delete")), + db: AsyncSession = Depends(get_pg_session), ): + melody = await service.get_built_melody(melody_id) await service.delete_built_melody(melody_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "archetype", + melody_id, melody.name if melody else melody_id) @router.post("/{melody_id}/toggle-builtin", response_model=BuiltMelodyInDB) diff --git a/backend/crm/customers_router.py b/backend/crm/customers_router.py index 5c6bfdf..b6ab6ea 100644 --- a/backend/crm/customers_router.py +++ b/backend/crm/customers_router.py @@ -2,16 +2,86 @@ import asyncio import logging from fastapi import APIRouter, Depends, Query, BackgroundTasks, Body from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse, TransactionEntry from crm import service, nextcloud from config import settings +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"]) logger = logging.getLogger(__name__) +# ── Diff helpers ────────────────────────────────────────────────────────────── + +_SCALAR_FIELDS = { + "name", "surname", "title", "organization", "religion", "language", + "relationship_status", "nextcloud_folder", +} +_SKIP_FIELDS = {"updated_at", "firestore_id", "id"} + + +def _scalar_diff(old, new) -> dict: + result = {} + for f in _SCALAR_FIELDS: + ov = getattr(old, f, None) + nv = getattr(new, f, None) + if ov != nv: + result[f] = {"old": ov, "new": nv} + return result + + +def _list_diff(field: str, old_list: list, new_list: list, label_fn) -> dict: + old_labels = {label_fn(i) for i in (old_list or [])} + new_labels = {label_fn(i) for i in (new_list or [])} + added = new_labels - old_labels + removed = old_labels - new_labels + result = {} + if added: + result[f"{field}.added"] = {"old": None, "new": sorted(added)} + if removed: + result[f"{field}.removed"] = {"old": sorted(removed), "new": None} + return result + + +def _customer_diff(old, new, changed_fields: set) -> dict: + changes = _scalar_diff(old, new) + + # contacts — keyed by type+value string + if "contacts" in changed_fields: + changes.update(_list_diff( + "contacts", + old.contacts or [], + new.contacts or [], + lambda c: f"{c.get('type','?')}:{c.get('value','?')}" if isinstance(c, dict) + else f"{getattr(c,'type','?')}:{getattr(c,'value','?')}", + )) + + # location — flatten to individual sub-fields + if "location" in changed_fields: + old_loc = old.location or {} + new_loc = new.location or {} + if isinstance(old_loc, object) and not isinstance(old_loc, dict): + old_loc = old_loc.model_dump() if hasattr(old_loc, "model_dump") else {} + if isinstance(new_loc, object) and not isinstance(new_loc, dict): + new_loc = new_loc.model_dump() if hasattr(new_loc, "model_dump") else {} + for k in set(old_loc) | set(new_loc): + ov, nv = old_loc.get(k), new_loc.get(k) + if ov != nv: + changes[f"location.{k}"] = {"old": ov, "new": nv} + + # tags + if "tags" in changed_fields: + old_tags = set(old.tags or []) + new_tags = set(new.tags or []) + if old_tags != new_tags: + changes["tags"] = {"old": sorted(old_tags), "new": sorted(new_tags)} + + return changes + @router.get("", response_model=CustomerListResponse) async def list_customers( @@ -46,10 +116,14 @@ async def create_customer( body: CustomerCreate, background_tasks: BackgroundTasks, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): customer = service.create_customer(body) if settings.nextcloud_url: background_tasks.add_task(_init_nextcloud_folder, customer) + label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer.id + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "customer", + customer.id, label) return customer @@ -65,12 +139,19 @@ async def _init_nextcloud_folder(customer) -> None: @router.put("/{customer_id}", response_model=CustomerInDB) -def update_customer( +async def update_customer( customer_id: str, body: CustomerUpdate, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_customer(customer_id, body) + old = service.get_customer(customer_id) + customer = service.update_customer(customer_id, body) + label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id + changes = _customer_diff(old, customer, body.model_fields_set) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "customer", + customer_id, label, changes=changes or None) + return customer @router.delete("/{customer_id}", status_code=204) @@ -80,6 +161,7 @@ async def delete_customer( wipe_files: bool = Query(False), wipe_nextcloud: bool = Query(False), _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): customer = service.delete_customer(customer_id) nc_path = service.get_customer_nc_path(customer) @@ -104,6 +186,10 @@ async def delete_customer( except Exception as e: logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e) + label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "customer", + customer_id, label) + @router.get("/{customer_id}/last-comm-direction") async def get_last_comm_direction( @@ -117,12 +203,17 @@ async def get_last_comm_direction( # ── Relationship Status ─────────────────────────────────────────────────────── @router.patch("/{customer_id}/relationship-status", response_model=CustomerInDB) -def update_relationship_status( +async def update_relationship_status( customer_id: str, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_relationship_status(customer_id, body.get("status", "")) + customer = service.update_relationship_status(customer_id, body.get("status", "")) + label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id + await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "customer", + customer_id, label, meta={"status": body.get("status", "")}) + return customer # ── Technical Issues ────────────────────────────────────────────────────────── diff --git a/backend/crm/orders_router.py b/backend/crm/orders_router.py index d38f851..9d6e01c 100644 --- a/backend/crm/orders_router.py +++ b/backend/crm/orders_router.py @@ -1,10 +1,13 @@ from fastapi import APIRouter, Depends, Query from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse from crm import service +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/crm/customers/{customer_id}/orders", tags=["crm-orders"]) @@ -29,27 +32,35 @@ def get_next_order_number( @router.post("/init-negotiations", response_model=OrderInDB, status_code=201) -def init_negotiations( +async def init_negotiations( customer_id: str, body: dict, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.init_negotiations( + order = service.init_negotiations( customer_id=customer_id, title=body.get("title", ""), note=body.get("note", ""), date=body.get("date"), created_by=body.get("created_by", ""), ) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "order", + order.id, order.order_number or order.id, meta={"action_detail": "negotiations_started"}) + return order @router.post("", response_model=OrderInDB, status_code=201) -def create_order( +async def create_order( customer_id: str, body: OrderCreate, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.create_order(customer_id, body) + order = service.create_order(customer_id, body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "order", + order.id, order.order_number or order.id) + return order @router.get("/{order_id}", response_model=OrderInDB) @@ -62,22 +73,37 @@ def get_order( @router.patch("/{order_id}", response_model=OrderInDB) -def update_order( +async def update_order( customer_id: str, order_id: str, body: OrderUpdate, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_order(customer_id, order_id, body) + old = service.get_order(customer_id, order_id) + order = service.update_order(customer_id, order_id, body) + action = "STATUS_CHANGE" if body.status is not None else "UPDATE" + _SKIP = {"updated_at", "id", "customer_id", "items", "timeline", "discount", "shipping", "payment_status"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(order, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(order, k, None) + } + await log_action(db, _user.sub, _user.name or _user.email, action, "order", + order_id, order.order_number or order_id, changes=changes or None) + return order @router.delete("/{order_id}", status_code=204) -def delete_order( +async def delete_order( customer_id: str, order_id: str, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): service.delete_order(customer_id, order_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "order", + order_id, order_id) @router.post("/{order_id}/timeline", response_model=OrderInDB) diff --git a/backend/crm/quotations_router.py b/backend/crm/quotations_router.py index 733d93a..465b3e4 100644 --- a/backend/crm/quotations_router.py +++ b/backend/crm/quotations_router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query, UploadFile, File from fastapi.responses import StreamingResponse from typing import Optional import io +from sqlalchemy.ext.asyncio import AsyncSession from auth.dependencies import require_permission from auth.models import TokenPayload @@ -13,6 +14,8 @@ from crm.quotation_models import ( QuotationUpdate, ) from crm import quotations_service as svc +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/crm/quotations", tags=["crm-quotations"]) @@ -72,11 +75,15 @@ async def create_quotation( body: QuotationCreate, generate_pdf: bool = Query(False), _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """ Create a quotation. Pass ?generate_pdf=true to immediately generate and upload the PDF. """ - return await svc.create_quotation(body, generate_pdf=generate_pdf) + q = await svc.create_quotation(body, generate_pdf=generate_pdf) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "quotation", + str(q.id), q.quotation_number or str(q.id)) + return q @router.put("/{quotation_id}", response_model=QuotationInDB) @@ -85,19 +92,34 @@ async def update_quotation( body: QuotationUpdate, generate_pdf: bool = Query(False), _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """ Update a quotation. Pass ?generate_pdf=true to regenerate the PDF. """ - return await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf) + old = await svc.get_quotation(quotation_id) + q = await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf) + _SKIP = {"updated_at", "id", "items", "pdf_path"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(q, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(q, k, None) + } + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "quotation", + quotation_id, q.quotation_number or quotation_id, changes=changes or None) + return q @router.delete("/{quotation_id}", status_code=204) async def delete_quotation( quotation_id: str, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): + q = await svc.get_quotation(quotation_id) await svc.delete_quotation(quotation_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "quotation", + quotation_id, q.quotation_number if q else quotation_id) @router.post("/{quotation_id}/regenerate-pdf", response_model=QuotationInDB) diff --git a/backend/crm/router.py b/backend/crm/router.py index f077507..b6e84b4 100644 --- a/backend/crm/router.py +++ b/backend/crm/router.py @@ -3,11 +3,14 @@ from fastapi.responses import FileResponse from typing import Optional import os import shutil +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse from crm import service +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/crm/products", tags=["crm-products"]) @@ -35,28 +38,47 @@ def get_product( @router.post("", response_model=ProductInDB, status_code=201) -def create_product( +async def create_product( body: ProductCreate, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.create_product(body) + product = service.create_product(body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "product", + product.id, product.name) + return product @router.put("/{product_id}", response_model=ProductInDB) -def update_product( +async def update_product( product_id: str, body: ProductUpdate, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_product(product_id, body) + old = service.get_product(product_id) + product = service.update_product(product_id, body) + _SKIP = {"updated_at", "id", "photo_url"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(product, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(product, k, None) + } + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "product", + product_id, product.name, changes=changes or None) + return product @router.delete("/{product_id}", status_code=204) -def delete_product( +async def delete_product( product_id: str, _user: TokenPayload = Depends(require_permission("crm", "edit")), + db: AsyncSession = Depends(get_pg_session), ): + product = service.get_product(product_id) service.delete_product(product_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "product", + product_id, product.name) @router.post("/{product_id}/photo", response_model=ProductInDB) diff --git a/backend/devices/router.py b/backend/devices/router.py index 9a33586..7508763 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -3,6 +3,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, Query, HTTPException from typing import Optional, List from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from devices.models import ( @@ -14,6 +15,8 @@ from devices import service import database as mqtt_db from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse from shared.firebase import get_db as get_firestore +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/devices", tags=["devices"]) @@ -58,8 +61,12 @@ async def get_device_users( async def create_device( body: DeviceCreate, _user: TokenPayload = Depends(require_permission("devices", "add")), + db: AsyncSession = Depends(get_pg_session), ): - return service.create_device(body) + device = service.create_device(body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device", + device.device_id, device.device_name or device.device_id) + return device @router.put("/{device_id}", response_model=DeviceInDB) @@ -67,16 +74,32 @@ async def update_device( device_id: str, body: DeviceUpdate, _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_device(device_id, body) + old = service.get_device(device_id) + device = service.update_device(device_id, body) + _SKIP = {"updated_at", "device_id", "tags", "user_list"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(device, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(device, k, None) + } + if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []): + changes["tags"] = {"old": sorted(old.tags or []), "new": sorted(device.tags or [])} + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device.device_name or device_id, changes=changes or None) + return device @router.delete("/{device_id}", status_code=204) async def delete_device( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "delete")), + db: AsyncSession = Depends(get_pg_session), ): service.delete_device(device_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "device", + device_id, device_id) @router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse) @@ -100,16 +123,16 @@ async def list_device_notes( ): """List all notes for a device.""" db = get_firestore() - docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream() + docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).stream() notes = [] for doc in docs: note = doc.to_dict() note["id"] = doc.id - # Convert Firestore Timestamps to ISO strings for f in ("created_at", "updated_at"): if hasattr(note.get(f), "isoformat"): note[f] = note[f].isoformat() notes.append(note) + notes.sort(key=lambda n: n.get("created_at") or "", reverse=False) return {"notes": notes, "total": len(notes)} @@ -251,6 +274,7 @@ async def assign_device_to_customer( device_id: str, body: AssignCustomerBody, _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Assign a device to a customer. @@ -290,6 +314,9 @@ async def assign_device_to_customer( }) customer_ref.update({"owned_items": owned_items}) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device_id, meta={"action_detail": "assigned_to_customer", + "customer_id": body.customer_id}) return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id} @@ -298,6 +325,7 @@ async def unassign_device_from_customer( device_id: str, customer_id: str = Query(...), _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Remove device assignment from a customer.""" db = get_firestore() @@ -317,6 +345,10 @@ async def unassign_device_from_customer( ] customer_ref.update({"owned_items": owned_items}) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device_id, meta={"action_detail": "unassigned_from_customer", + "customer_id": customer_id}) + # ───────────────────────────────────────────────────────────────────────────── # Customer detail (for Owner display in fleet) @@ -402,6 +434,7 @@ async def add_user_to_device( device_id: str, body: AddUserBody, _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Add a user reference to the device's user_list field.""" db = get_firestore() @@ -432,6 +465,9 @@ async def add_user_to_device( user_list.append(user_ref) device_ref.update({"user_list": user_list}) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device_id, meta={"action_detail": "user_added", + "user_id": body.user_id}) return {"status": "added", "user_id": body.user_id} @@ -440,6 +476,7 @@ async def remove_user_from_device( device_id: str, user_id: str, _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Remove a user reference from the device's user_list field.""" db = get_firestore() @@ -464,4 +501,7 @@ async def remove_user_from_device( new_list = [entry for entry in user_list if not resolves_to(entry, user_id)] device_ref.update({"user_list": new_list}) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device_id, meta={"action_detail": "user_removed", + "user_id": user_id}) return {"status": "removed", "user_id": user_id} diff --git a/backend/firmware/router.py b/backend/firmware/router.py index 6ba2d50..953092a 100644 --- a/backend/firmware/router.py +++ b/backend/firmware/router.py @@ -3,11 +3,14 @@ from fastapi.responses import FileResponse, PlainTextResponse from pydantic import BaseModel from typing import Optional import logging +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType from firmware import service +from database.postgres import get_pg_session +from shared.audit import log_action logger = logging.getLogger(__name__) @@ -27,9 +30,10 @@ async def upload_firmware( bespoke_uid: Optional[str] = Form(None), file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), + db: AsyncSession = Depends(get_pg_session), ): file_bytes = await file.read() - return service.upload_firmware( + fw = service.upload_firmware( hw_type=hw_type, channel=channel, version=version, @@ -40,6 +44,9 @@ async def upload_firmware( release_note=release_note, bespoke_uid=bespoke_uid, ) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware", + fw.id, f"{hw_type} v{version} ({channel})") + return fw @router.get("", response_model=FirmwareListResponse) @@ -108,9 +115,10 @@ async def edit_firmware( bespoke_uid: Optional[str] = Form(None), file: Optional[UploadFile] = File(None), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), + db: AsyncSession = Depends(get_pg_session), ): file_bytes = await file.read() if file and file.filename else None - return service.edit_firmware( + fw = service.edit_firmware( doc_id=firmware_id, channel=channel, version=version, @@ -121,14 +129,22 @@ async def edit_firmware( bespoke_uid=bespoke_uid, file_bytes=file_bytes, ) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "firmware", + firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id) + return fw @router.delete("/{firmware_id}", status_code=204) -def delete_firmware( +async def delete_firmware( firmware_id: str, _user: TokenPayload = Depends(require_permission("manufacturing", "delete")), + db: AsyncSession = Depends(get_pg_session), ): + fw = service.get_firmware(firmware_id) if hasattr(service, "get_firmware") else None service.delete_firmware(firmware_id) + label = f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "firmware", + firmware_id, label) # ───────────────────────────────────────────────────────────────────────────── diff --git a/backend/main.py b/backend/main.py index bd3e4ca..be206ca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,6 +28,7 @@ from public.router import router as public_router from notes.router import router as notes_router from tickets.router import router as tickets_router from audit.router import router as audit_router +from search.router import router as search_router from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive from crm.mail_accounts import get_mail_accounts from mqtt.client import mqtt_manager @@ -76,6 +77,7 @@ app.include_router(public_router) app.include_router(notes_router) app.include_router(tickets_router) app.include_router(audit_router) +app.include_router(search_router) async def nextcloud_keepalive_loop(): diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 5b58fa8..085cfd2 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -1,11 +1,14 @@ from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from melodies.models import ( MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo, ) from melodies import service +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/melodies", tags=["melodies"]) @@ -42,8 +45,12 @@ async def create_melody( body: MelodyCreate, publish: bool = Query(False), _user: TokenPayload = Depends(require_permission("melodies", "add")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.create_melody(body, publish=publish, actor_name=_user.name) + melody = await service.create_melody(body, publish=publish, actor_name=_user.name) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "melody", + melody.id, melody.information.name if melody.information else melody.id) + return melody @router.put("/{melody_id}", response_model=MelodyInDB) @@ -51,32 +58,61 @@ async def update_melody( melody_id: str, body: MelodyUpdate, _user: TokenPayload = Depends(require_permission("melodies", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.update_melody(melody_id, body, actor_name=_user.name) + old = await service.get_melody(melody_id) + melody = await service.update_melody(melody_id, body, actor_name=_user.name) + _SKIP = {"updated_at", "id", "metadata", "information", "noteAssignments"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(melody, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(melody, k, None) + } + # Surface the name change from inside the information sub-object + old_name = old.information.name if old.information else None + new_name = melody.information.name if melody.information else None + if old_name != new_name: + changes["name"] = {"old": old_name, "new": new_name} + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "melody", + melody_id, new_name or melody_id, changes=changes or None) + return melody @router.delete("/{melody_id}", status_code=204) async def delete_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "delete")), + db: AsyncSession = Depends(get_pg_session), ): + melody = await service.get_melody(melody_id) + label = melody.information.name if melody.information else melody_id await service.delete_melody(melody_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "melody", + melody_id, label) @router.post("/{melody_id}/publish", response_model=MelodyInDB) async def publish_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.publish_melody(melody_id) + melody = await service.publish_melody(melody_id) + await log_action(db, _user.sub, _user.name or _user.email, "PUBLISH", "melody", + melody_id, melody.information.name if melody.information else melody_id) + return melody @router.post("/{melody_id}/unpublish", response_model=MelodyInDB) async def unpublish_melody( melody_id: str, _user: TokenPayload = Depends(require_permission("melodies", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return await service.unpublish_melody(melody_id) + melody = await service.unpublish_melody(melody_id) + await log_action(db, _user.sub, _user.name or _user.email, "UNPUBLISH", "melody", + melody_id, melody.information.name if melody.information else melody_id) + return melody @router.post("/{melody_id}/upload/{file_type}") diff --git a/backend/notes/router.py b/backend/notes/router.py index 19e7f4b..ba74871 100644 --- a/backend/notes/router.py +++ b/backend/notes/router.py @@ -6,6 +6,7 @@ from auth.dependencies import require_permission from auth.models import TokenPayload from notes import service from notes.models import EntryCreate, EntryUpdate, EntryOut, EntryListResponse, LinksReplaceIn +from shared.audit import log_action router = APIRouter(prefix="/api/notes", tags=["notes"]) @@ -49,7 +50,10 @@ async def create_entry( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "add")), ): - return await service.create_entry(db, body, _user.sub, _user.name or _user.email) + entry = await service.create_entry(db, body, _user.sub, _user.name or _user.email) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "note", + str(entry.id), entry.title or entry.type) + return entry @router.patch("/{entry_id}", response_model=EntryOut) @@ -58,7 +62,10 @@ async def update_entry( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): - return await service.update_entry(db, entry_id, body) + entry = await service.update_entry(db, entry_id, body) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "note", + str(entry_id), entry.title or entry.type) + return entry @router.patch("/{entry_id}/links", response_model=EntryOut) @@ -67,7 +74,11 @@ async def replace_links( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): - return await service.replace_links(db, entry_id, body.links) + entry = await service.replace_links(db, entry_id, body.links) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "note", + str(entry_id), entry.title or entry.type, + meta={"action_detail": "links_updated"}) + return entry @router.delete("/{entry_id}", status_code=204) @@ -76,4 +87,7 @@ async def delete_entry( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "delete")), ): + entry = await service.get_entry(db, entry_id) await service.delete_entry(db, entry_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "note", + str(entry_id), entry.title or entry.type if entry else str(entry_id)) diff --git a/backend/search/__init__.py b/backend/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/search/router.py b/backend/search/router.py new file mode 100644 index 0000000..d572dbc --- /dev/null +++ b/backend/search/router.py @@ -0,0 +1,163 @@ +import asyncio +import json +from fastapi import APIRouter, Depends, Query +from auth.dependencies import get_current_user +from auth.models import TokenPayload +from devices import service as devices_service +from users import service as users_service +from crm import service as crm_service +from melodies import service as melodies_service + +router = APIRouter(prefix="/api/search", tags=["search"]) + +LIMIT = 5 + + +def _truncate(s: str, n: int = 48) -> str: + if not s: + return "" + return s if len(s) <= n else s[:n - 1] + "…" + + +def _search_devices(q: str) -> list[dict]: + try: + results = devices_service.list_devices(search=q) + except Exception: + return [] + out = [] + for d in results[:LIMIT]: + label = d.device_name or d.serial_number or d.device_id or d.id + sublabel = d.serial_number if d.device_name else None + out.append({ + "type": "device", + "id": d.id, + "label": _truncate(label), + "sublabel": _truncate(sublabel) if sublabel else None, + "url": f"/devices/{d.id}", + }) + return out + + +def _search_users(q: str) -> list[dict]: + try: + results = users_service.list_users(search=q) + except Exception: + return [] + out = [] + for u in results[:LIMIT]: + label = u.display_name or u.email or u.id + sublabel = u.email if u.display_name else None + out.append({ + "type": "user", + "id": u.id, + "label": _truncate(label), + "sublabel": _truncate(sublabel) if sublabel else None, + "url": f"/users/{u.id}", + }) + return out + + +def _search_customers(q: str) -> list[dict]: + try: + results = crm_service.list_customers(search=q) + except Exception: + return [] + out = [] + for c in results[:LIMIT]: + name_parts = [c.name, c.surname] + label = " ".join(p for p in name_parts if p) or c.organization or c.id + sublabel_parts = [] + if c.organization and (c.name or c.surname): + sublabel_parts.append(c.organization) + if c.location: + if c.location.city: + sublabel_parts.append(c.location.city) + if c.location.country: + sublabel_parts.append(c.location.country) + out.append({ + "type": "customer", + "id": c.id, + "label": _truncate(label), + "sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None, + "url": f"/crm/customers/{c.id}", + }) + return out + + +def _search_products(q: str) -> list[dict]: + try: + results = crm_service.list_products(search=q) + except Exception: + return [] + out = [] + for p in results[:LIMIT]: + sublabel_parts = [] + if p.category: + sublabel_parts.append(p.category.value.replace("_", " ").title()) + if p.sku: + sublabel_parts.append(p.sku) + out.append({ + "type": "product", + "id": p.id, + "label": _truncate(p.name or p.id), + "sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None, + "url": f"/crm/products/{p.id}", + }) + return out + + +async def _search_melodies(q: str) -> list[dict]: + try: + results = await melodies_service.list_melodies(search=q) + except Exception: + return [] + out = [] + for m in results[:LIMIT]: + try: + name_dict = json.loads(m.information.name) if m.information.name else {} + label = name_dict.get("en") or name_dict.get("gr") or next(iter(name_dict.values()), None) or m.id + except Exception: + label = m.information.name or m.id + sublabel_parts = [] + if m.pid: + sublabel_parts.append(m.pid) + if m.information.melodyTone: + sublabel_parts.append(m.information.melodyTone.value.title()) + if m.information.totalActiveBells: + sublabel_parts.append(f"{m.information.totalActiveBells} bells") + out.append({ + "type": "melody", + "id": m.id, + "label": _truncate(label), + "sublabel": _truncate(" · ".join(sublabel_parts)) if sublabel_parts else None, + "url": f"/melodies/{m.id}", + }) + return out + + +@router.get("") +async def global_search( + q: str = Query(..., min_length=1, max_length=100), + _user: TokenPayload = Depends(get_current_user), +): + q = q.strip() + if not q: + return {"results": []} + + # Run sync searches in a thread pool, melody search is already async + loop = asyncio.get_event_loop() + devices_fut = loop.run_in_executor(None, _search_devices, q) + users_fut = loop.run_in_executor(None, _search_users, q) + customers_fut = loop.run_in_executor(None, _search_customers, q) + products_fut = loop.run_in_executor(None, _search_products, q) + melodies_task = _search_melodies(q) + + devices, users, customers, products, melodies = await asyncio.gather( + devices_fut, users_fut, customers_fut, products_fut, melodies_task + ) + + results = [] + for group in (devices, users, customers, products, melodies): + results.extend(group) + + return {"results": results} diff --git a/backend/shared/audit.py b/backend/shared/audit.py index 8ab1b8e..4456610 100644 --- a/backend/shared/audit.py +++ b/backend/shared/audit.py @@ -43,6 +43,10 @@ async def log_action( """ Insert one row into audit_log. Never raises — failures are silently swallowed so a logging error never disrupts the primary request. + + Always commits its own mini-transaction. Callers that run inside a larger + transaction (e.g. staff service) should commit themselves after calling this; + the extra commit here is a no-op if the session is already clean. """ try: entry = AuditLog( @@ -57,12 +61,9 @@ async def log_action( meta=meta, ) db.add(entry) - # Flush without committing — caller's transaction commits it atomically. - # If the caller hasn't started a transaction, flush still works; the - # session will auto-commit on the next explicit commit call. - await db.flush() + await db.commit() except Exception: - pass + await db.rollback() def diff(old: dict, new: dict) -> dict[str, dict]: diff --git a/backend/tickets/router.py b/backend/tickets/router.py index 0477359..9b23c6f 100644 --- a/backend/tickets/router.py +++ b/backend/tickets/router.py @@ -6,6 +6,7 @@ from auth.dependencies import require_permission from auth.models import TokenPayload from tickets import service from tickets.models import TicketCreate, TicketUpdate, MessageCreate, EscalateIn, TicketOut, TicketListResponse +from shared.audit import log_action router = APIRouter(prefix="/api/tickets", tags=["tickets"]) @@ -57,7 +58,10 @@ async def create_ticket( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "add")), ): - return await service.create_ticket(db, body) + ticket = await service.create_ticket(db, body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "ticket", + str(ticket.id), ticket.subject) + return ticket @router.patch("/{ticket_id}", response_model=TicketOut) @@ -66,7 +70,11 @@ async def update_ticket( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): - return await service.update_ticket(db, ticket_id, body) + ticket = await service.update_ticket(db, ticket_id, body) + action = "STATUS_CHANGE" if body.status is not None else "UPDATE" + await log_action(db, _user.sub, _user.name or _user.email, action, "ticket", + str(ticket_id), ticket.subject) + return ticket @router.post("/{ticket_id}/messages", response_model=TicketOut) @@ -75,7 +83,10 @@ async def add_message( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): - return await service.add_message(db, ticket_id, body) + ticket = await service.add_message(db, ticket_id, body) + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "ticket", + str(ticket_id), ticket.subject, meta={"action_detail": "message_added"}) + return ticket @router.post("/{ticket_id}/escalate", response_model=TicketOut) @@ -84,4 +95,7 @@ async def escalate( db: AsyncSession = Depends(get_pg_session), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): - return await service.escalate_to_issue(db, ticket_id, body.entry_id) + ticket = await service.escalate_to_issue(db, ticket_id, body.entry_id) + await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "ticket", + str(ticket_id), ticket.subject, meta={"action_detail": "escalated_to_issue"}) + return ticket diff --git a/backend/users/router.py b/backend/users/router.py index f98ccd9..8540307 100644 --- a/backend/users/router.py +++ b/backend/users/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, Query, UploadFile, File from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from users.models import ( @@ -7,6 +8,8 @@ from users.models import ( SetPasswordRequest, ResetPasswordRequest, ) from users import service +from database.postgres import get_pg_session +from shared.audit import log_action router = APIRouter(prefix="/api/users", tags=["users"]) @@ -33,8 +36,12 @@ async def get_user( async def create_user( body: UserCreate, _user: TokenPayload = Depends(require_permission("app_users", "add")), + db: AsyncSession = Depends(get_pg_session), ): - return service.create_user(body) + app_user = service.create_user(body) + await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "app_user", + app_user.id, app_user.display_name or app_user.email or app_user.id) + return app_user @router.put("/{user_id}", response_model=UserInDB) @@ -42,32 +49,57 @@ async def update_user( user_id: str, body: UserUpdate, _user: TokenPayload = Depends(require_permission("app_users", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.update_user(user_id, body) + old = service.get_user(user_id) + app_user = service.update_user(user_id, body) + _SKIP = {"updated_at", "id", "photo_url"} + changes = { + k: {"old": getattr(old, k, None), "new": getattr(app_user, k, None)} + for k in body.model_fields_set + if k not in _SKIP and getattr(old, k, None) != getattr(app_user, k, None) + } + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "app_user", + user_id, app_user.display_name or app_user.email or user_id, + changes=changes or None) + return app_user @router.delete("/{user_id}", status_code=204) async def delete_user( user_id: str, _user: TokenPayload = Depends(require_permission("app_users", "delete")), + db: AsyncSession = Depends(get_pg_session), ): service.delete_user(user_id) + await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "app_user", + user_id, user_id) @router.post("/{user_id}/block", response_model=UserInDB) async def block_user( user_id: str, _user: TokenPayload = Depends(require_permission("app_users", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.block_user(user_id) + app_user = service.block_user(user_id) + await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "app_user", + user_id, app_user.display_name or app_user.email or user_id, + meta={"status": "blocked"}) + return app_user @router.post("/{user_id}/unblock", response_model=UserInDB) async def unblock_user( user_id: str, _user: TokenPayload = Depends(require_permission("app_users", "edit")), + db: AsyncSession = Depends(get_pg_session), ): - return service.unblock_user(user_id) + app_user = service.unblock_user(user_id) + await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "app_user", + user_id, app_user.display_name or app_user.email or user_id, + meta={"status": "unblocked"}) + return app_user @router.get("/{user_id}/devices", response_model=List[dict]) diff --git a/docker-compose.yml b/docker-compose.yml index 4e7bb0b..54f26dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: backend: build: ./backend - container_name: bellsystems-backend-v2 + container_name: bellsystems-backend env_file: .env volumes: - ./backend:/app @@ -11,29 +11,27 @@ services: - ./data/flash_assets:/app/storage/flash_assets - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro ports: - - "8002:8000" # different port — v1 backend runs on 8000 - depends_on: - postgres: - condition: service_healthy + - "8000:8000" networks: - internal frontend: build: ./frontend - container_name: bellsystems-frontend-v2 + container_name: bellsystems-frontend volumes: - ./frontend:/app - /app/node_modules ports: - - "5174:5174" # different port — v1 frontend runs on 5173 + - "5173:5174" + - "8001:5174" networks: - internal nginx: image: nginx:alpine - container_name: bellsystems-nginx-v2 + container_name: bellsystems-nginx ports: - - "8001:80" # access v2 on localhost:8001 + - "80:80" # access v2 on localhost:8001 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: @@ -42,26 +40,6 @@ services: networks: - internal - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - internal - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - postgres_data: - networks: internal: driver: bridge diff --git a/frontend/src/assets/side-menu-icons/inventory.svg b/frontend/src/assets/side-menu-icons/inventory.svg new file mode 100644 index 0000000..4e351d0 --- /dev/null +++ b/frontend/src/assets/side-menu-icons/inventory.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 7861b92..70d9515 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -44,7 +44,7 @@ const STATIC_LABELS = { staff: 'Staff', sn: 'S/N Manager', 'staff-log': 'Staff Log', - 'serial-logs': 'Log Viewer', + 'audit-log': 'Log Viewer', 'public-features': 'Public Features', } @@ -282,8 +282,9 @@ const SETTINGS_ITEMS = [ ), }, { - to: '/settings/serial-logs', + to: '/settings/audit-log', label: 'Log Viewer', + sysadminOnly: true, icon: (
- +
{settingsOpen && ( - setSettingsOpen(false)} /> + setSettingsOpen(false)} isSysadmin={hasRole('sysadmin')} /> )} )} diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index fad8173..42f4a97 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -1,20 +1,40 @@ // frontend/src/components/layout/Sidebar.jsx // Primary navigation sidebar — 224px wide, fixed, full height. -// -// Visual style (matches Stitch reference): -// - Brand header with logo at top -// - Section labels: plain uppercase text, generous padding, no rule lines -// - Nav items: px-6 py-3, full width, 3px left bar + primary-subtle bg when active -// - Collapsible groups: same row height as nav items -// - Children: darker inset bg, deep left-indent, text-color hover -// - Console Settings: pinned at bottom import { useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { useAuth } from '@/hooks/useAuth' import logoDark from '@/assets/logos/bell_systems_horizontal_darkMode.png' -// ─── Icon set ───────────────────────────────────────────────────────────────── +// ─── SVG file icons (vite-plugin-svgr v4 — ?react query) ───────────────────── + +import IcoDevices from '@/assets/side-menu-icons/devices.svg?react' +import IcoDeviceOverview from '@/assets/side-menu-icons/device-overview.svg?react' +import IcoFleet from '@/assets/side-menu-icons/fleet.svg?react' +import IcoBlackbox from '@/assets/side-menu-icons/blackbox.svg?react' +import IcoMelodies from '@/assets/side-menu-icons/melodies.svg?react' +import IcoMelodiesEditor from '@/assets/side-menu-icons/melodies-editor.svg?react' +import IcoComposer from '@/assets/side-menu-icons/composer.svg?react' +import IcoArchetypes from '@/assets/side-menu-icons/archetypes.svg?react' +import IcoMelodySettings from '@/assets/side-menu-icons/melody-settings.svg?react' +import IcoCommunications from '@/assets/side-menu-icons/communications.svg?react' +import IcoWhatsapp from '@/assets/side-menu-icons/whatsapp.svg?react' +import IcoSms from '@/assets/side-menu-icons/sms.svg?react' +import IcoHelpdesk from '@/assets/side-menu-icons/helpdesk.svg?react' +import IcoCommsLog from '@/assets/side-menu-icons/communications-log.svg?react' +import IcoCustomers from '@/assets/side-menu-icons/customers.svg?react' +import IcoCustomerOverview from '@/assets/side-menu-icons/customer-overview.svg?react' +import IcoOrders from '@/assets/side-menu-icons/orders.svg?react' +import IcoProducts from '@/assets/side-menu-icons/products.svg?react' +import IcoCatalog from '@/assets/side-menu-icons/product-catalog.svg?react' +import IcoSnManager from '@/assets/side-menu-icons/sn-manager.svg?react' +import IcoManufacturing from '@/assets/side-menu-icons/manufacturing.svg?react' +import IcoInventory from '@/assets/side-menu-icons/inventory.svg?react' +import IcoProvisioning from '@/assets/side-menu-icons/provision.svg?react' +import IcoFirmware from '@/assets/side-menu-icons/firmware.svg?react' +import IcoApi from '@/assets/side-menu-icons/api.svg?react' + +// ─── Inline-only icons (no SVG file equivalent) ─────────────────────────────── const S = ({ children, ...p }) => ( ( ) +// Wrapper to normalise imported SVG file components to 16×16 +function SvgIcon({ Component }) { + return ( +