update: Add Global Search on Header, Add Global Audit log for all actions.

This commit is contained in:
2026-04-19 15:41:29 +03:00
parent 4f35bef6e3
commit 6a958a8d7d
27 changed files with 2086 additions and 267 deletions

View File

@@ -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)

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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)
# ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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():

View File

@@ -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}")

View File

@@ -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))

View File

163
backend/search/router.py Normal file
View File

@@ -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}

View File

@@ -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]:

View File

@@ -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

View File

@@ -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])

View File

@@ -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

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="currentColor" height="800px" width="800px" version="1.2" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 256 188" xml:space="preserve">
<g>
<g>
<g>
<path d="M63,133c-13,0-23.5,10.5-23.5,23.5s10.5,23.5,23.5,23.5c13,0,23.5-10.5,23.5-23.5S76,133,63,133z M63,165.4
c-4.9,0-9-4.1-9-9c0-4.9,4.1-9,9-9c4.9,0,9,4.1,9,9C72,161.4,68,165.4,63,165.4z M210.8,132c-13,0-23.5,10.5-23.5,23.5
s10.5,23.5,23.5,23.5c13,0,23.5-10.5,23.5-23.5S223.8,132,210.8,132z M210.8,164.4c-4.9,0-9-4.1-9-9c0-4.9,4.1-9,9-9
c4.9,0,9,4.1,9,9C219.8,160.4,215.8,164.4,210.8,164.4z M-0.5,143.1c0,4.6,3.7,8.2,8.2,8.2h22.6c0.9,0,1.7-0.7,1.9-1.5
c2.6-14.7,15.4-24.9,30.8-24.9s28.3,10.2,30.8,24.9c0.2,0.9,0.9,1.5,1.9,1.5H99h30.9V115H-0.5V143.1z M253.6,134.5h-5v-22
c0-7.5-6.1-13.6-13.7-13.6h-24.3c-0.5,0-1-0.3-1.4-0.6l-38-37c-1.7-1.7-4.1-2.7-6.6-2.8h-27.5v92.8h40.9c0.9,0,1.7-0.7,1.9-1.5
c2.6-14.7,15.4-25.9,30.8-25.9s28.3,11.2,30.8,25.9c0.2,0.9,0.9,1.5,1.9,1.5h3.2c4.9,0,8.7-3.9,8.7-8.7v-6.3
C255.5,135.4,254.6,134.5,253.6,134.5z M191.1,99h-41.4c-1,0-1.9-0.9-1.9-1.9V70.7c0-1,0.9-1.9,1.9-1.9h13.9c0.5,0,1,0.3,1.5,0.6
l27.5,26.3C193.5,97,192.7,99,191.1,99z"/>
</g>
</g>
</g>
<path d="M57.8,101.5H17.1V60.8h15.7v13h9.3v-13h15.7V101.5z M110.9,101.5H70.3V60.8H86v13h9.3v-13h15.7V101.5z M84.7,48.3H44V7.6
h15.7v13H69v-13h15.7V48.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="1.5"/>
@@ -304,7 +305,7 @@ const SETTINGS_ITEMS = [
},
]
function SettingsMenu({ onClose }) {
function SettingsMenu({ onClose, isSysadmin }) {
const navigate = useNavigate()
useEffect(() => {
@@ -342,7 +343,7 @@ function SettingsMenu({ onClose }) {
}}>
Console Settings
</div>
{SETTINGS_ITEMS.map((item) => (
{SETTINGS_ITEMS.filter((item) => !item.sysadminOnly || isSysadmin).map((item) => (
<button
key={item.to}
type="button"
@@ -397,7 +398,6 @@ function SettingsMenu({ onClose }) {
export default function Header({ onMenuOpen }) {
const { user, logout, hasRole } = useAuth()
const [search, setSearch] = useState('')
const [profileOpen, setProfileOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const profileRef = useRef(null)
@@ -448,7 +448,7 @@ export default function Header({ onMenuOpen }) {
<div className="header-right">
<div className="header-search">
<HeaderSearch value={search} onChange={setSearch} placeholder="Search…" />
<HeaderSearch placeholder="Search…" />
</div>
<button type="button" className="header-icon-btn" aria-label="Notifications">
@@ -469,7 +469,7 @@ export default function Header({ onMenuOpen }) {
<GearIcon />
</button>
{settingsOpen && (
<SettingsMenu onClose={() => setSettingsOpen(false)} />
<SettingsMenu onClose={() => setSettingsOpen(false)} isSysadmin={hasRole('sysadmin')} />
)}
</div>
)}

View File

@@ -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 }) => (
<svg
@@ -29,38 +49,50 @@ const S = ({ children, ...p }) => (
</svg>
)
// Wrapper to normalise imported SVG file components to 16×16
function SvgIcon({ Component }) {
return (
<Component
width="16" height="16"
aria-hidden="true" focusable="false"
style={{ flexShrink: 0 }}
/>
)
}
const Icons = {
dashboard: () => <S><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></S>,
devices: () => <S><rect x="2" y="3" width="12" height="8" rx="1.5"/><path d="M5 14h6M8 11v3"/></S>,
deviceOverview: () => <S><circle cx="8" cy="6" r="3"/><path d="M3 13c0-2.76 2.24-5 5-5s5 2.24 5 5"/></S>,
fleet: () => <S><path d="M2 5h12M2 8h9M2 11h6"/></S>,
devices: () => <SvgIcon Component={IcoDevices} />,
deviceOverview: () => <SvgIcon Component={IcoDeviceOverview} />,
fleet: () => <SvgIcon Component={IcoFleet} />,
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
blackBox: () => <S><rect x="2" y="4" width="12" height="8" rx="1"/><path d="M5 8h6"/></S>,
blackBox: () => <SvgIcon Component={IcoBlackbox} />,
deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />,
appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>,
melodies: () => <S><path d="M9 3v7"/><path d="M9 3l4-1v7"/><circle cx="7" cy="10" r="2"/><circle cx="11" cy="9" r="2"/></S>,
library: () => <S><rect x="2" y="2" width="4" height="12" rx="1"/><rect x="7" y="4" width="4" height="10" rx="1"/><rect x="12" y="2" width="2" height="12" rx="1"/></S>,
composer: () => <S><path d="M2 12L10 4l2 2-8 8H2v-2z"/><path d="M8 6l2 2"/></S>,
archetypes: () => <S><path d="M8 2l2 4h4l-3 3 1 4-4-2.5L4 13l1-4-3-3h4z"/></S>,
melodySettings: () => <S><path d="M2 4h2"/><path d="M6 4h8"/><circle cx="5" cy="4" r="1.5" fill="currentColor" stroke="none"/><path d="M2 8h6"/><path d="M10 8h4"/><circle cx="9" cy="8" r="1.5" fill="currentColor" stroke="none"/><path d="M2 12h9"/><path d="M13 12h1"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></S>,
communications: () => <S><path d="M2 3h12v8H2z"/><path d="M5 14l3-3 3 3"/></S>,
melodies: () => <SvgIcon Component={IcoMelodies} />,
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
composer: () => <SvgIcon Component={IcoComposer} />,
archetypes: () => <SvgIcon Component={IcoArchetypes} />,
melodySettings: () => <SvgIcon Component={IcoMelodySettings} />,
communications: () => <SvgIcon Component={IcoCommunications} />,
mail: () => <S><rect x="2" y="4" width="12" height="9" rx="1"/><path d="M2 5l6 4 6-4"/></S>,
whatsapp: () => <S><path d="M8 2a6 6 0 0 1 6 6c0 3.31-2.69 6-6 6a5.97 5.97 0 0 1-3.1-.86L2 14l.86-2.9A5.97 5.97 0 0 1 2 8a6 6 0 0 1 6-6z"/></S>,
sms: () => <S><path d="M2 3h12v8H8l-3 2.5V11H2z"/></S>,
helpdesk: () => <S><path d="M8 2a4 4 0 0 0-4 4c0 1.5.82 2.8 2 3.46V11h4V9.46A4 4 0 0 0 8 2z"/><path d="M6 13h4"/><path d="M8 11v2"/></S>,
commsLog: () => <S><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></S>,
customers: () => <S><rect x="3" y="2" width="10" height="7" rx="1"/><path d="M1 14c0-2.76 3.13-5 7-5s7 2.24 7 5"/></S>,
customerOverview: () => <S><path d="M2 12l3-5 3 3 2-4 4 6"/></S>,
orders: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 6h4M6 9h4M6 12h2"/></S>,
whatsapp: () => <SvgIcon Component={IcoWhatsapp} />,
sms: () => <SvgIcon Component={IcoSms} />,
helpdesk: () => <SvgIcon Component={IcoHelpdesk} />,
commsLog: () => <SvgIcon Component={IcoCommsLog} />,
customers: () => <SvgIcon Component={IcoCustomers} />,
customerOverview: () => <SvgIcon Component={IcoCustomerOverview} />,
orders: () => <SvgIcon Component={IcoOrders} />,
quotations: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 5h4M6 8h3M9 11l1-1 1 1 1-3"/></S>,
products: () => <S><path d="M8 2L2 5v6l6 3 6-3V5z"/><path d="M8 2v9M2 5l6 3 6-3"/></S>,
catalog: () => <S><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></S>,
snManager: () => <S><rect x="2" y="5" width="12" height="6" rx="1"/><path d="M4 8h1M6 8h1M8 8h1M10 8h1"/></S>,
products: () => <SvgIcon Component={IcoProducts} />,
catalog: () => <SvgIcon Component={IcoCatalog} />,
snManager: () => <SvgIcon Component={IcoSnManager} />,
staffLog: () => <S><rect x="4" y="2" width="8" height="2" rx="1"/><rect x="2" y="3" width="12" height="11" rx="1"/><path d="M5 8h6M5 11h4"/></S>,
manufacturing: () => <S><path d="M2 12l3-6 3 3 2-5 4 8H2z"/><circle cx="5" cy="5" r="1.5"/></S>,
inventory: () => <S><rect x="2" y="7" width="5" height="7" rx="1"/><rect x="5.5" y="4" width="5" height="10" rx="1"/><rect x="9" y="2" width="5" height="12" rx="1"/></S>,
provisioning: () => <S><path d="M8 2v8"/><path d="M5 8l3 3 3-3"/><path d="M3 13h10"/></S>,
firmware: () => <S><rect x="3" y="4" width="10" height="8" rx="1"/><path d="M6 7h4M7 10h2"/><path d="M6 2h4M6 14h4"/></S>,
api: () => <S><path d="M4 6l-2 2 2 2M12 6l2 2-2 2M9 4l-2 8"/></S>,
manufacturing: () => <SvgIcon Component={IcoManufacturing} />,
inventory: () => <SvgIcon Component={IcoInventory} />,
provisioning: () => <SvgIcon Component={IcoProvisioning} />,
firmware: () => <SvgIcon Component={IcoFirmware} />,
api: () => <SvgIcon Component={IcoApi} />,
settings: () => <S><circle cx="8" cy="8" r="2.5"/><path d="M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1"/></S>,
staff: () => <S><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.31 2.69-6 6-6s6 2.69 6 6"/></S>,
publicFeatures: () => <S><path d="M2 8a6 6 0 1 0 12 0A6 6 0 0 0 2 8z"/><path d="M8 2a9 9 0 0 0 0 12M8 2a9 9 0 0 1 0 12M2 8h12"/></S>,
@@ -99,7 +131,7 @@ const navSections = [
{ to: '/devices', label: 'Fleet', icon: 'fleet', exact: true },
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
{ to: '/devices/settings', label: 'Device Settings', icon: 'settings', placeholder: true },
{ to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true },
],
},
{ to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' },
@@ -225,7 +257,7 @@ function CollapsibleGroup({ label, icon, children, currentPath, locked, open, on
end={child.exact === true}
className={({ isActive }) => `nav-child-link${isActive ? ' active' : ''}`}
>
{({ isActive }) => {
{() => {
const ChildIcon = Icons[child.icon] ?? Icons.placeholder
return (
<>
@@ -274,7 +306,7 @@ export default function Sidebar() {
<img
src={logoDark}
alt="BellSystems"
style={{ height: '18px', width: 'auto', objectFit: 'contain' }}
className="sidebar-brand-logo"
/>
</div>

View File

@@ -1,22 +1,47 @@
// frontend/src/components/ui/HeaderSearch.jsx
// Minimal pill-shaped search input for the top bar.
//
// Intentionally different from <SearchBar>:
// - Background: --color-bg-surface (not abyss/elevated)
// - Shape: fully pill-rounded (--radius-full)
// - No border, no shadow, no focus ring outline — purely minimal
//
// Props:
// value — string — controlled value
// onChange — fn(str) — called on every keystroke
// placeholder — string — defaults to "Search…"
// Global search bar for the top header.
// Debounces 500ms → GET /api/search?q= → floating results panel.
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import IcoDevices from '@/assets/side-menu-icons/devices.svg?react'
import IcoCustomers from '@/assets/side-menu-icons/customers.svg?react'
import IcoProducts from '@/assets/side-menu-icons/products.svg?react'
import IcoMelodies from '@/assets/side-menu-icons/melodies.svg?react'
// App Users has no sidebar SVG file — keep inline
function IcoUsers() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true" focusable="false"
>
<circle cx="6" cy="5" r="2.5" />
<path d="M2 13c0-2.2 1.8-4 4-4" />
<circle cx="12" cy="7" r="2" />
<path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3" />
</svg>
)
}
// ─── Type config ──────────────────────────────────────────────────────────────
const TYPE_META = {
device: { label: 'Devices', accent: 'var(--color-info)', Icon: IcoDevices },
user: { label: 'Users', accent: 'var(--color-success)', Icon: IcoUsers },
customer: { label: 'Customers', accent: 'var(--color-primary)', Icon: IcoCustomers },
product: { label: 'Products', accent: 'var(--color-warning)', Icon: IcoProducts },
melody: { label: 'Melodies', accent: '#f0c040', Icon: IcoMelodies },
}
// ─── Search / spinner icons ───────────────────────────────────────────────────
function SearchIcon() {
return (
<svg
width="13" height="13" viewBox="0 0 13 13"
fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"
stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true" focusable="false"
>
<circle cx="5.5" cy="5.5" r="4" />
@@ -25,22 +50,195 @@ function SearchIcon() {
)
}
export default function HeaderSearch({ value, onChange, placeholder = 'Search…' }) {
function SpinnerIcon() {
return (
<div className="v2-topbar-search">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"
stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"
aria-hidden="true" focusable="false"
style={{ animation: 'spin 0.7s linear infinite' }}
>
<circle cx="6.5" cy="6.5" r="4" strokeOpacity="0.25" />
<path d="M6.5 2.5a4 4 0 0 1 4 4" />
</svg>
)
}
// ─── Result item ──────────────────────────────────────────────────────────────
function ResultItem({ result, isActive, onMouseEnter, onMouseLeave, onClick }) {
const meta = TYPE_META[result.type] || { label: result.type, accent: 'var(--color-text-muted)', Icon: null }
const { Icon, accent } = meta
return (
<button
type="button"
className={`gs-result-item${isActive ? ' gs-result-item--active' : ''}`}
style={{ '--gs-accent': accent }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
tabIndex={-1}
>
<span
className="gs-result-icon"
style={{ color: accent, backgroundColor: `color-mix(in srgb, ${accent} 12%, transparent)` }}
>
{Icon && <Icon width="14" height="14" aria-hidden="true" focusable="false" style={{ flexShrink: 0 }} />}
</span>
<span className="gs-result-body">
<span className="gs-result-label">{result.label}</span>
{result.sublabel && (
<span className="gs-result-sublabel">{result.sublabel}</span>
)}
</span>
</button>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function HeaderSearch({ placeholder = 'Search…' }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const navigate = useNavigate()
const wrapRef = useRef(null)
const inputRef = useRef(null)
const debounceRef = useRef(null)
const requestIdRef = useRef(0)
useEffect(() => {
clearTimeout(debounceRef.current)
const q = query.trim()
if (q.length < 2) {
setResults([])
setLoading(false)
setOpen(false)
setActiveIdx(-1)
return
}
setLoading(true)
const reqId = ++requestIdRef.current
debounceRef.current = setTimeout(async () => {
try {
const data = await api.get(`/search?q=${encodeURIComponent(q)}`)
if (reqId !== requestIdRef.current) return
setResults(data.results || [])
setOpen(true)
setActiveIdx(-1)
} catch {
if (reqId === requestIdRef.current) setResults([])
} finally {
if (reqId === requestIdRef.current) setLoading(false)
}
}, 500)
return () => clearTimeout(debounceRef.current)
}, [query])
useEffect(() => {
if (!open) return
const handler = (e) => {
if (!wrapRef.current?.contains(e.target)) {
setOpen(false)
setActiveIdx(-1)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const navigateTo = useCallback((url) => {
setOpen(false)
setQuery('')
setResults([])
setActiveIdx(-1)
navigate(url)
}, [navigate])
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setOpen(false)
setQuery('')
setActiveIdx(-1)
return
}
if (!open || !results.length) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIdx >= 0 && results[activeIdx]) navigateTo(results[activeIdx].url)
}
}
const grouped = results.reduce((acc, r, i) => {
if (!acc[r.type]) acc[r.type] = []
acc[r.type].push({ ...r, _idx: i })
return acc
}, {})
const isEmpty = open && !loading && results.length === 0 && query.trim().length >= 2
return (
<div className="v2-topbar-search" ref={wrapRef}>
<span className="v2-topbar-search-icon">
<SearchIcon />
{loading ? <SpinnerIcon /> : <SearchIcon />}
</span>
<input
ref={inputRef}
type="search"
className="v2-topbar-search-input"
value={value}
onChange={(e) => onChange(e.target.value)}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) setOpen(true) }}
placeholder={placeholder}
aria-label={placeholder}
autoComplete="off"
spellCheck={false}
/>
{(open || isEmpty) && (
<div className="gs-dropdown" role="listbox">
{isEmpty ? (
<div className="gs-empty">
<span className="gs-empty-icon"><SearchIcon /></span>
<span>No results for <em>&ldquo;{query.trim()}&rdquo;</em></span>
</div>
) : (
Object.entries(grouped).map(([type, items], groupIdx) => {
const meta = TYPE_META[type] || { label: type, accent: 'var(--color-text-muted)' }
return (
<div key={type} className="gs-group">
{groupIdx > 0 && <div className="gs-divider" />}
<div className="gs-group-header" style={{ color: meta.accent }}>
{meta.label}
<span className="gs-group-count">{items.length}</span>
</div>
{items.map((r) => (
<ResultItem
key={`${r.type}-${r.id}`}
result={r}
isActive={r._idx === activeIdx}
onMouseEnter={() => setActiveIdx(r._idx)}
onMouseLeave={() => setActiveIdx(-1)}
onClick={() => navigateTo(r.url)}
/>
))}
</div>
)
})
)}
</div>
)}
</div>
)
}

View File

@@ -299,6 +299,7 @@ export default function DeviceDetail() {
onEditBacklight: () => setEditingBacklight(true),
onEditSubscription: () => setEditingSubscription(true),
onEditWarranty: () => setEditingWarranty(true),
onNavigateToManage: () => handleTabChange('manage'),
}
// ── Render ─────────────────────────────────────────────────────────────────

View File

@@ -431,97 +431,7 @@ export default function ManageTab({ device, canEdit, deviceUsers: propUsers, use
</div>
</GlassCard>
{/* ── 2. DEVICE NOTES ──────────────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Device Notes"
count={notes.length || undefined}
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
+ Add Note
</Button>
)}
/>
<div style={{ padding: notesLoading || notes.length === 0 ? 0 : 'var(--space-4) var(--space-5)' }}>
{notesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
) : notes.length === 0 ? (
<EmptySlate
icon={
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
}
message="No notes recorded for this device yet."
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
Add first note
</Button>
)}
/>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-3)' }}>
{notes.map(note => (
<div
key={note.id}
className="manage-note-card"
style={{
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
padding: 'var(--space-4)',
borderRadius: 'var(--radius-lg)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-sm)',
}}
>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', flex: 1 }}>
{note.content || '—'}
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fmtDateMedium(note.created_at)}>
{fmtRelative(note.created_at)}
</span>
{note.created_by && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.created_by}
</span>
)}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
<button onClick={() => setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="edit" size={13} />
</button>
<button onClick={() => setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</GlassCard>
{/* ── 3. LINKED ISSUES ──────────────────────────────────────────────── */}
{/* ── 2. LINKED ISSUES ──────────────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Linked Issues"
@@ -605,6 +515,96 @@ export default function ManageTab({ device, canEdit, deviceUsers: propUsers, use
</div>
</GlassCard>
{/* ── 3. DEVICE NOTES ───────────────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Device Notes"
count={notes.length || undefined}
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
+ Add Note
</Button>
)}
/>
<div style={{ padding: notesLoading || notes.length === 0 ? 0 : 'var(--space-4) var(--space-5)' }}>
{notesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
) : notes.length === 0 ? (
<EmptySlate
icon={
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
}
message="No notes recorded for this device yet."
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
Add first note
</Button>
)}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{notes.map(note => (
<div
key={note.id}
className="manage-note-card"
style={{
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
padding: 'var(--space-4)',
borderRadius: 'var(--radius-lg)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-sm)',
}}
>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{note.content || '—'}
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fmtDateMedium(note.created_at)}>
{fmtRelative(note.created_at)}
</span>
{note.created_by && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.created_by}
</span>
)}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
<button onClick={() => setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="edit" size={13} />
</button>
<button onClick={() => setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</GlassCard>
</div>
{/* ── Modals ─────────────────────────────────────────────────────────── */}

View File

@@ -168,6 +168,7 @@ export default function OverviewTab({
setStaffNotes,
deviceUsers,
usersLoading,
onNavigateToManage,
}) {
const navigate = useNavigate()
const { toast } = useToast()
@@ -242,8 +243,27 @@ export default function OverviewTab({
useEffect(() => { loadNotes() }, [loadNotes])
const [issues, setIssues] = useState([])
const [issuesLoading, setIssuesLoading] = useState(false)
const loadIssues = useCallback(async () => {
if (!id) return
setIssuesLoading(true)
try {
const data = await api.get(`/notes/by-entity/device/${id}`)
setIssues(Array.isArray(data) ? data : [])
} catch {
setIssues([])
} finally {
setIssuesLoading(false)
}
}, [id])
useEffect(() => { loadIssues() }, [loadIssues])
const [noteModal, setNoteModal] = useState({ open: false })
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
const [viewNote, setViewNote] = useState(null)
const handleNoteSaved = () => {
setNoteModal({ open: false })
@@ -252,6 +272,7 @@ export default function OverviewTab({
const handleIssueSaved = () => {
setIssueModal({ open: false, entry: null })
loadIssues()
}
// ── Live logs ─────────────────────────────────────────────────────────────
@@ -708,7 +729,7 @@ export default function OverviewTab({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<SectionLabel>Issues &amp; Notes</SectionLabel>
{notes.length > 0 && (
{(notes.length + issues.length) > 0 && (
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
@@ -717,22 +738,28 @@ export default function OverviewTab({
borderRadius: 'var(--radius-full)',
border: '1px solid var(--color-border)',
}}>
{notes.length}
{notes.length + issues.length}
</span>
)}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 'var(--space-2)', flexShrink: 0 }}>
{canEdit && (
<>
<Button variant="ghost" size="sm" icon={<Icon name="plus" size={12} />} onClick={() => setNoteModal({ open: true })}>
Add Note
</Button>
<Button variant="ghost" size="sm" icon={<Icon name="plus" size={12} />} onClick={() => setIssueModal({ open: true, entry: null })}>
Record Issue
</Button>
</div>
</>
)}
<Button variant="ghost" size="sm" onClick={onNavigateToManage}>
Edit
</Button>
</div>
</div>
{/* Notes */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{notesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
@@ -743,24 +770,42 @@ export default function OverviewTab({
No notes for this device.
</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-3)' }}>
<div style={{
display: 'grid',
gridTemplateColumns: notes.length === 1 ? '1fr' : notes.length === 2 ? '1fr 1fr' : 'repeat(3, 1fr)',
gap: 'var(--space-3)',
}}>
{notes.map((note, i) => (
<div
key={note.id || i}
onClick={() => setViewNote(note)}
style={{
padding: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.5, marginBottom: note.created_at ? 'var(--space-2)' : 0 }}>
<p style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-primary)',
lineHeight: 1.5,
marginBottom: note.created_at ? 'var(--space-2)' : 0,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}>
{note.content || note.text || '—'}
</p>
{note.created_at && (
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{fmtRelative(note.created_at)}
{note.author_name ? ` · ${note.author_name}` : ''}
{note.created_by ? ` · ${note.created_by}` : ''}
</p>
)}
</div>
@@ -768,6 +813,72 @@ export default function OverviewTab({
</div>
)}
</div>
{/* Issues */}
{(issuesLoading || issues.length > 0) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', borderTop: '1px solid var(--color-border)', paddingTop: 'var(--space-3)' }}>
<SectionLabel>Linked Issues</SectionLabel>
{issuesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
<Spinner size="sm" />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{issues.map((issue, i) => {
const statusColors = {
open: { color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
researching: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
resolved: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
}
const sc = statusColors[issue.status] || statusColors.open
return (
<div
key={issue.id || i}
onClick={() => setIssueModal({ open: true, entry: issue })}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 8px',
borderRadius: 'var(--radius-full)',
backgroundColor: sc.bg,
border: `1px solid ${sc.color}33`,
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: sc.color,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: sc.color, flexShrink: 0 }} />
{issue.status || 'open'}
</span>
<span style={{ flex: 1, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{issue.title}
</span>
{issue.created_at && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{fmtRelative(issue.created_at)}
</span>
)}
</div>
)
})}
</div>
)}
</div>
)}
</GlassCard>
</div>
@@ -1047,13 +1158,72 @@ export default function OverviewTab({
<EntryFormModal
open={issueModal.open}
entry={null}
entry={issueModal.entry}
defaultType="issue"
prefilledLinks={[{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]}
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]}
knownEntities={id ? { [id]: device?.device_name || sn } : undefined}
onClose={() => setIssueModal({ open: false, entry: null })}
onSaved={handleIssueSaved}
/>
{/* ── Note full-view mini modal ──────────────────────────────────────── */}
{viewNote && (
<div
onClick={() => setViewNote(null)}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 'var(--space-6)',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
maxWidth: 560,
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 0,
overflow: 'hidden',
}}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--space-4) var(--space-5)',
borderBottom: '1px solid var(--color-border)',
}}>
<SectionLabel>Note</SectionLabel>
<button
onClick={() => setViewNote(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', padding: 4, borderRadius: 'var(--radius-sm)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div style={{ padding: 'var(--space-5)', overflowY: 'auto', maxHeight: '60vh' }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{viewNote.content || viewNote.text || '—'}
</p>
</div>
{viewNote.created_at && (
<div style={{ padding: 'var(--space-3) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{fmtRelative(viewNote.created_at)}
{viewNote.created_by ? ` · ${viewNote.created_by}` : ''}
</span>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -844,8 +844,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/partitions.bin`
const nvsUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0&nvs_profile=${nvsProfile}`
: `/api/manufacturing/devices/${sn}/nvs.bin?nvs_profile=${nvsProfile}`
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0&nvs_schema=${nvsProfile}`
: `/api/manufacturing/devices/${sn}/nvs.bin?nvs_schema=${nvsProfile}`
const fwUrl = bespokeOverride
? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
: `/api/manufacturing/devices/${sn}/firmware.bin`
@@ -1037,8 +1037,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
{error && <div style={{ marginBottom: 'var(--space-3)' }}><ErrorBox msg={error} /></div>}
{/* Progress bars — shown while flashing */}
{(flashing || blProgress > 0) && (
{/* Progress bars — always visible, idle at 0% */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
<div style={{ display: 'flex', gap: 'var(--space-4)' }}>
<ProgressBar flex label="Bootloader (0x1000)" percent={blProgress} />
@@ -1047,7 +1046,6 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
<ProgressBar label="NVS (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
@@ -1109,7 +1107,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
height: 320,
minHeight: 320,
}}>
<div style={{
padding: '8px 12px',

View File

@@ -0,0 +1,754 @@
// frontend/src/pages/settings/LogViewerPage.jsx
// Audit log viewer — filterable, paginated, expandable rows.
// Route: /settings/audit-log (wired in router/index.jsx)
import { useState, useEffect, useCallback, useRef } from 'react'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import PageHeader from '@/components/ui/PageHeader'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Pagination from '@/components/ui/Pagination'
import SearchBar from '@/components/ui/SearchBar'
import Select from '@/components/ui/Select'
import Button from '@/components/ui/Button'
import { fmtDateTimeMedium } from '@/lib/formatters'
// ─── Constants ────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
const ACTION_META = {
CREATE: { label: 'Create', variant: 'success' },
UPDATE: { label: 'Update', variant: 'info' },
DELETE: { label: 'Delete', variant: 'danger' },
COMMAND: { label: 'Command', variant: 'warning' },
PUBLISH: { label: 'Publish', variant: 'success' },
UNPUBLISH: { label: 'Unpublish', variant: 'neutral' },
LOGIN: { label: 'Login', variant: 'neutral' },
LOGOUT: { label: 'Logout', variant: 'neutral' },
PERMISSION_CHANGE:{ label: 'Permissions', variant: 'warning' },
STATUS_CHANGE: { label: 'Status Change', variant: 'info' },
}
const ENTITY_LABELS = {
customer: 'Customer',
order: 'Order',
device: 'Device',
melody: 'Melody',
product: 'Product',
staff: 'Staff',
ticket: 'Ticket',
note: 'Note',
quotation: 'Quotation',
firmware: 'Firmware',
archetype: 'Archetype',
}
const ACTION_OPTIONS = [
{ value: '', label: 'All Actions' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
{ value: 'COMMAND', label: 'Command' },
{ value: 'PUBLISH', label: 'Publish' },
{ value: 'UNPUBLISH', label: 'Unpublish' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'PERMISSION_CHANGE', label: 'Permissions' },
{ value: 'STATUS_CHANGE', label: 'Status Change' },
]
const ENTITY_OPTIONS = [
{ value: '', label: 'All Entities' },
...Object.entries(ENTITY_LABELS).map(([k, v]) => ({ value: k, label: v })),
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
function actionMeta(action) {
return ACTION_META[action] ?? { label: action, variant: 'neutral' }
}
function buildParams({ actorId, action, entityType, fromDate, toDate, offset }) {
const p = new URLSearchParams()
if (actorId) p.set('actor_id', actorId)
if (action) p.set('action', action)
if (entityType) p.set('entity_type', entityType)
if (fromDate) p.set('from_date', new Date(fromDate).toISOString())
if (toDate) p.set('to_date', new Date(toDate + 'T23:59:59').toISOString())
p.set('limit', String(PAGE_SIZE))
p.set('offset', String(offset))
return p.toString()
}
// ─── Changes diff renderer ────────────────────────────────────────────────────
function ChangesDiff({ changes }) {
if (!changes || Object.keys(changes).length === 0) return null
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-1)',
marginTop: 'var(--space-3)',
}}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
marginBottom: 'var(--space-1)',
}}>
Changes
</span>
{Object.entries(changes).map(([field, { old: oldVal, new: newVal }]) => (
<div key={field} style={{
display: 'grid',
gridTemplateColumns: '140px 1fr 1fr',
gap: 'var(--space-2)',
alignItems: 'start',
fontSize: 'var(--font-size-sm)',
padding: 'var(--space-2) var(--space-3)',
backgroundColor: 'var(--color-bg-abyss)',
borderRadius: 'var(--radius-sm)',
}}>
<span style={{
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
wordBreak: 'break-all',
}}>
{field}
</span>
<span style={{
color: 'var(--color-danger)',
wordBreak: 'break-all',
fontFamily: oldVal !== null && typeof oldVal === 'string' ? undefined : 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
}}>
{oldVal === null || oldVal === undefined ? <em style={{ color: 'var(--color-text-muted)' }}>null</em> : String(oldVal)}
</span>
<span style={{
color: 'var(--color-success)',
wordBreak: 'break-all',
fontFamily: newVal !== null && typeof newVal === 'string' ? undefined : 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
}}>
{newVal === null || newVal === undefined ? <em style={{ color: 'var(--color-text-muted)' }}>null</em> : String(newVal)}
</span>
</div>
))}
</div>
)
}
// ─── Meta renderer ────────────────────────────────────────────────────────────
function MetaBlock({ meta }) {
if (!meta || Object.keys(meta).length === 0) return null
return (
<div style={{ marginTop: 'var(--space-3)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
display: 'block',
marginBottom: 'var(--space-1)',
}}>
Context
</span>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--space-2)',
}}>
{Object.entries(meta).map(([k, v]) => (
<span key={k} style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
fontSize: 'var(--font-size-xs)',
padding: '2px var(--space-2)',
backgroundColor: 'var(--color-bg-abyss)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-secondary)',
}}>
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>{k}</span>
<span style={{ color: 'var(--color-text-muted)' }}>·</span>
<span style={{ fontFamily: 'var(--font-family-mono)' }}>{String(v)}</span>
</span>
))}
</div>
</div>
)
}
// ─── Row ──────────────────────────────────────────────────────────────────────
function LogRow({ entry, isExpanded, onToggle }) {
const { label, variant } = actionMeta(entry.action)
const entityLabel = ENTITY_LABELS[entry.entity_type] ?? entry.entity_type
const hasDetail = (entry.changes && Object.keys(entry.changes).length > 0)
|| (entry.meta && Object.keys(entry.meta).length > 0)
return (
<>
<tr
className={`log-row${isExpanded ? ' log-row--expanded' : ''}${hasDetail ? ' log-row--clickable' : ''}`}
onClick={hasDetail ? onToggle : undefined}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}
>
{/* Timestamp */}
<td className="log-cell log-cell--ts">
<span style={{
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
whiteSpace: 'nowrap',
}}>
{fmtDateTimeMedium(entry.occurred_at)}
</span>
</td>
{/* Actor */}
<td className="log-cell">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<div style={{
width: 24,
height: 24,
borderRadius: 'var(--radius-full)',
background: 'var(--color-primary-subtle)',
border: '1px solid rgba(192,193,255,0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontFamily: 'var(--font-family-display)',
fontWeight: 'var(--font-weight-semibold)',
fontSize: '10px',
color: 'var(--color-primary)',
letterSpacing: '0.02em',
}}>
{entry.actor_name?.charAt(0)?.toUpperCase() ?? '?'}
</div>
<span style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-primary)',
fontWeight: 'var(--font-weight-medium)',
}}>
{entry.actor_name}
</span>
</div>
</td>
{/* Action badge */}
<td className="log-cell">
<StatusBadge variant={variant}>{label}</StatusBadge>
</td>
{/* Entity */}
<td className="log-cell">
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
fontWeight: 'var(--font-weight-semibold)',
}}>
{entityLabel}
</span>
{entry.entity_label && (
<span style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-primary)',
fontWeight: 'var(--font-weight-medium)',
}}>
{entry.entity_label}
</span>
)}
</div>
</td>
{/* Entity ID */}
<td className="log-cell log-cell--id">
<span style={{
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
}}>
{entry.entity_id?.length > 16
? entry.entity_id.slice(0, 8) + '…' + entry.entity_id.slice(-4)
: entry.entity_id}
</span>
</td>
{/* Expand chevron */}
<td className="log-cell log-cell--expand" style={{ textAlign: 'right', width: 32 }}>
{hasDetail && (
<svg
width="12" height="12" viewBox="0 0 12 12" fill="none"
stroke="currentColor" strokeWidth="1.8"
strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true"
style={{
display: 'block',
marginLeft: 'auto',
color: 'var(--color-text-muted)',
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 150ms ease',
}}
>
<path d="M2 4l4 4 4-4" />
</svg>
)}
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && hasDetail && (
<tr className="log-row-detail">
<td colSpan={6} style={{ padding: 0 }}>
<div style={{
padding: 'var(--space-4) var(--space-6)',
backgroundColor: 'var(--color-bg-void)',
borderBottom: '1px solid var(--color-border)',
}}>
<ChangesDiff changes={entry.changes} />
<MetaBlock meta={entry.meta} />
</div>
</td>
</tr>
)}
</>
)
}
// ─── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonRows() {
return Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="log-row">
{[140, 120, 80, 140, 100, 24].map((w, j) => (
<td key={j} className="log-cell">
<div style={{
height: 14,
width: w,
maxWidth: '100%',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--color-bg-elevated)',
opacity: 0.6,
}} />
</td>
))}
</tr>
))
}
// ─── Filters bar ──────────────────────────────────────────────────────────────
function FiltersBar({ filters, setFilters, staffList, onReset }) {
const hasFilters = filters.actorId || filters.action || filters.entityType
|| filters.fromDate || filters.toDate
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 'var(--space-3)',
}}>
{/* Actor / staff member */}
<div style={{ minWidth: 160 }}>
<Select
value={filters.actorId}
onChange={(e) => setFilters(f => ({ ...f, actorId: e.target.value, offset: 0 }))}
placeholder="All Staff"
>
<option value="">All Staff</option>
{staffList.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</Select>
</div>
{/* Action */}
<div style={{ minWidth: 140 }}>
<Select
value={filters.action}
onChange={(e) => setFilters(f => ({ ...f, action: e.target.value, offset: 0 }))}
placeholder="All Actions"
>
{ACTION_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</Select>
</div>
{/* Entity type */}
<div style={{ minWidth: 140 }}>
<Select
value={filters.entityType}
onChange={(e) => setFilters(f => ({ ...f, entityType: e.target.value, offset: 0 }))}
placeholder="All Entities"
>
{ENTITY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</Select>
</div>
{/* Date range */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
type="date"
value={filters.fromDate}
onChange={(e) => setFilters(f => ({ ...f, fromDate: e.target.value, offset: 0 }))}
aria-label="From date"
className="log-date-input"
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}></span>
<input
type="date"
value={filters.toDate}
onChange={(e) => setFilters(f => ({ ...f, toDate: e.target.value, offset: 0 }))}
aria-label="To date"
className="log-date-input"
/>
</div>
{/* Reset */}
{hasFilters && (
<Button variant="ghost" size="sm" onClick={onReset}>
Clear filters
</Button>
)}
</div>
)
}
// ─── Summary stats ────────────────────────────────────────────────────────────
function StatPill({ label, value, variant }) {
const colors = {
success: 'var(--color-success)',
danger: 'var(--color-danger)',
warning: 'var(--color-warning)',
info: 'var(--color-info)',
neutral: 'var(--color-text-muted)',
}
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
minWidth: 80,
}}>
<span style={{
width: 6,
height: 6,
borderRadius: 'var(--radius-full)',
backgroundColor: colors[variant] ?? colors.neutral,
flexShrink: 0,
}} />
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
fontWeight: 'var(--font-weight-semibold)',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-mono)',
marginLeft: 'auto',
}}>
{value}
</span>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
const INITIAL_FILTERS = {
actorId: '',
action: '',
entityType: '',
fromDate: '',
toDate: '',
offset: 0,
}
export default function LogViewerPage() {
const { user } = useAuth()
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [total, setTotal] = useState(null)
const [staffList, setStaffList] = useState([])
const [filters, setFilters] = useState(INITIAL_FILTERS)
const [expandedId, setExpandedId] = useState(null)
const [stats, setStats] = useState(null)
// Derived pagination
const page = Math.floor(filters.offset / PAGE_SIZE) + 1
const pageCount = total != null ? Math.ceil(total / PAGE_SIZE) : 0
// Load staff list once for the actor dropdown
useEffect(() => {
api.get('/staff').then(data => {
setStaffList((data.staff ?? data ?? []).map(s => ({ id: s.id, name: s.name })))
}).catch(() => {})
}, [])
// Load entries whenever filters change
const fetchEntries = useCallback(async () => {
setLoading(true)
setError(null)
setExpandedId(null)
try {
const qs = buildParams(filters)
const data = await api.get(`/audit-log?${qs}`)
const rows = data.entries ?? []
setEntries(rows)
// The API doesn't return total — fetch count via a separate offset-trick:
// if we got a full page, there may be more; signal unknown total for now.
setTotal(null)
// Build quick stats from current page
const actionCounts = {}
rows.forEach(r => { actionCounts[r.action] = (actionCounts[r.action] ?? 0) + 1 })
setStats(actionCounts)
} catch (err) {
setError(err?.message ?? 'Failed to load audit log.')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { fetchEntries() }, [fetchEntries])
function handleReset() {
setFilters(INITIAL_FILTERS)
}
function toggleExpand(id) {
setExpandedId(prev => prev === id ? null : id)
}
// ── Render states ────────────────────────────────────────────────────────
const isEmpty = !loading && !error && entries.length === 0
return (
<>
<style>{`
.log-row {
border-bottom: 1px solid var(--color-border);
transition: background-color 80ms;
}
.log-row:hover {
background-color: var(--color-bg-elevated);
}
.log-row--expanded {
background-color: var(--color-bg-elevated);
}
.log-row--clickable:hover {
background-color: var(--color-bg-island);
}
.log-row-detail {
background-color: var(--color-bg-void);
}
.log-cell {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.log-cell--ts {
width: 148px;
white-space: nowrap;
}
.log-cell--id {
width: 120px;
}
.log-cell--expand {
width: 40px;
padding-right: var(--space-4);
}
.log-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.log-table thead th {
padding: var(--space-2) var(--space-4);
text-align: left;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
border-bottom: 1px solid var(--color-border-strong);
white-space: nowrap;
background-color: var(--color-bg-void);
}
.log-date-input {
height: 32px;
padding: 0 var(--space-3);
background-color: var(--color-bg-abyss);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-family: var(--font-family-base);
outline: none;
transition: border-color 150ms;
color-scheme: dark;
}
.log-date-input:focus {
border-color: var(--color-border-focus);
box-shadow: var(--shadow-focus);
}
.log-date-input::-webkit-calendar-picker-indicator {
filter: invert(0.6);
cursor: pointer;
}
`}</style>
<div className="page-wrapper">
<PageHeader
title="Log Viewer"
subtitle="Staff actions and system events across the console"
/>
{/* ── Stats strip ─────────────────────────────────────────────── */}
{stats && Object.keys(stats).length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{Object.entries(stats)
.sort((a, b) => b[1] - a[1])
.map(([action, count]) => {
const { label, variant } = actionMeta(action)
return <StatPill key={action} label={label} value={count} variant={variant} />
})}
</div>
)}
{/* ── Filters ──────────────────────────────────────────────────── */}
<FiltersBar
filters={filters}
setFilters={setFilters}
staffList={staffList}
onReset={handleReset}
/>
{/* ── Table ────────────────────────────────────────────────────── */}
<div style={{
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
boxShadow: 'var(--shadow-card)',
}}>
<table className="log-table" aria-label="Audit log">
<thead>
<tr>
<th style={{ width: 148 }}>Timestamp</th>
<th style={{ width: 160 }}>Staff</th>
<th style={{ width: 110 }}>Action</th>
<th>Entity</th>
<th style={{ width: 120 }}>ID</th>
<th style={{ width: 40 }} />
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonRows />
) : error ? (
<tr>
<td colSpan={6} style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ color: 'var(--color-danger)', opacity: 0.7 }}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
<Button variant="ghost" size="sm" onClick={fetchEntries}>Retry</Button>
</div>
</td>
</tr>
) : isEmpty ? (
<tr>
<td colSpan={6} style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ color: 'var(--color-text-muted)' }}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M8 9h8M8 13h5" />
</svg>
<p style={{ fontSize: 'var(--font-size-base)', color: 'var(--color-text-muted)' }}>
No entries match these filters
</p>
<Button variant="ghost" size="sm" onClick={handleReset}>
Clear filters
</Button>
</div>
</td>
</tr>
) : (
entries.map(entry => (
<LogRow
key={entry.id}
entry={entry}
isExpanded={expandedId === entry.id}
onToggle={() => toggleExpand(entry.id)}
/>
))
)}
</tbody>
</table>
</div>
{/* ── Pagination ────────────────────────────────────────────────── */}
{!loading && !error && entries.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Showing {filters.offset + 1}{filters.offset + entries.length}
{entries.length === PAGE_SIZE && ' · more available'}
</span>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<Button
variant="secondary"
size="sm"
disabled={filters.offset === 0}
onClick={() => setFilters(f => ({ ...f, offset: Math.max(0, f.offset - PAGE_SIZE) }))}
>
Previous
</Button>
<Button
variant="secondary"
size="sm"
disabled={entries.length < PAGE_SIZE}
onClick={() => setFilters(f => ({ ...f, offset: f.offset + PAGE_SIZE }))}
>
Next
</Button>
</div>
</div>
)}
</div>
</>
)
}

View File

@@ -31,6 +31,7 @@ import StaffList from '@/pages/settings/staff/StaffList'
import StaffDetail from '@/pages/settings/staff/StaffDetail'
import StaffForm from '@/pages/settings/staff/StaffForm'
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
import LogViewerPage from '@/pages/settings/LogViewerPage'
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
import CustomerList from '@/pages/crm/customers/CustomerList'
@@ -194,7 +195,7 @@ export default function V2Router() {
<Route path="settings/staff/:id/edit" element={<RoleGate roles={['sysadmin', 'admin']}><StaffForm /></RoleGate>} />
<Route path="settings/public-features" element={<RoleGate roles={['sysadmin', 'admin']}><PublicFeaturesSettings /></RoleGate>} />
<Route path="settings/automations" element={<RoleGate roles={['sysadmin', 'admin']}><AutomationsPage /></RoleGate>} />
<Route path="settings/serial-logs" element={<RoleGate roles={['sysadmin', 'admin']}><ComingSoon /></RoleGate>} />
<Route path="settings/audit-log" element={<RoleGate roles={['sysadmin']}><LogViewerPage /></RoleGate>} />
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -40,6 +40,10 @@
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ==========================================================================
BUTTON (.btn)
@@ -1819,6 +1823,13 @@ tr:focus-within .btn-table-actions {
flex-shrink: 0;
}
.sidebar-brand-logo {
width: 100%;
height: auto;
display: block;
object-fit: contain;
}
/* Sidebar scrollable nav area */
.sidebar-nav {
flex: 1;
@@ -2223,6 +2234,159 @@ tr:focus-within .btn-table-actions {
appearance: none;
}
/* ─── Global search dropdown ─────────────────────────────────────────────── */
.gs-dropdown {
position: absolute;
top: calc(100% + var(--space-2));
left: 50%;
transform: translateX(-50%);
width: 572px;
max-height: 520px;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-bg-void);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 9999;
padding: var(--space-3);
animation: slide-up 0.14s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Section grouping */
.gs-group {
padding-bottom: var(--space-1);
}
.gs-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) var(--space-1);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.07em;
text-transform: uppercase;
opacity: 0.75;
}
.gs-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
font-weight: var(--font-weight-semibold);
background: var(--color-bg-elevated);
color: var(--color-text-muted);
border-radius: var(--radius-full);
line-height: 1;
}
.gs-divider {
height: 1px;
background: var(--color-border);
margin: var(--space-2) 0;
}
/* Result item — 2-row card */
.gs-result-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: background-color 0.1s ease;
min-width: 0;
}
.gs-result-item--active::before {
content: '';
position: absolute;
inset: 0% 0%;
background: var(--gs-accent, var(--color-text-muted));
border-radius: 0%;
filter: blur(8px);
opacity: 0.10;
pointer-events: none;
z-index: 0;
}
.gs-result-item > * {
position: relative;
z-index: 1;
}
/* Icon chip */
.gs-result-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-md);
}
/* Text block — stacked 2 rows */
.gs-result-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.gs-result-label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.gs-result-sublabel {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
font-family: var(--font-family-mono);
letter-spacing: 0.01em;
}
/* Empty state */
.gs-empty {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.gs-empty-icon {
flex-shrink: 0;
opacity: 0.4;
display: flex;
}
.gs-empty em {
font-style: normal;
color: var(--color-text-secondary);
}
/* Icon buttons: bell, gear */
.header-icon-btn {
display: flex;