Compare commits

...

2 Commits

Author SHA1 Message Date
8c15c932b6 chore: untrack .claude/ folder and update .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:39:42 +02:00
c62188fda6 update: Major Overhaul to all subsystems 2026-03-07 11:36:46 +02:00
95 changed files with 19121 additions and 946 deletions

View File

@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(python -c:*)",
"Bash(npx vite build:*)",
"Bash(wc:*)",
"Bash(ls:*)",
"Bash(node -c:*)",
"Bash(npm run lint:*)",
"Bash(python:*)"
]
}
}

View File

@@ -13,6 +13,8 @@ MQTT_BROKER_PORT=1883
MQTT_ADMIN_USERNAME=admin MQTT_ADMIN_USERNAME=admin
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
# Must be unique per running instance (VPS vs local dev)
MQTT_CLIENT_ID=bellsystems-admin-panel
# HMAC secret used to derive per-device MQTT passwords (must match firmware) # HMAC secret used to derive per-device MQTT passwords (must match firmware)
MQTT_SECRET=change-me-in-production MQTT_SECRET=change-me-in-production
@@ -26,3 +28,10 @@ NGINX_PORT=80
SQLITE_DB_PATH=./mqtt_data.db SQLITE_DB_PATH=./mqtt_data.db
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
FIRMWARE_STORAGE_PATH=./storage/firmware FIRMWARE_STORAGE_PATH=./storage/firmware
# Nextcloud WebDAV
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_USERNAME=service-account@example.com
NEXTCLOUD_PASSWORD=your-password-here
NEXTCLOUD_DAV_USER=admin
NEXTCLOUD_BASE_PATH=BellSystems/Console

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ Thumbs.db
.MAIN-APP-REFERENCE/ .MAIN-APP-REFERENCE/
.project-vesper-plan.md .project-vesper-plan.md
# claude
.claude/

View File

@@ -1,5 +1,15 @@
FROM python:3.11-slim FROM python:3.11-slim
# WeasyPrint system dependencies (libpango, libcairo, etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
fonts-dejavu-core \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .

View File

@@ -10,45 +10,141 @@ class Role(str, Enum):
user = "user" user = "user"
class SectionPermissions(BaseModel): class MelodiesPermissions(BaseModel):
view: bool = False
add: bool = False
delete: bool = False
safe_edit: bool = False
full_edit: bool = False
archetype_access: bool = False
settings_access: bool = False
compose_access: bool = False
class DevicesPermissions(BaseModel):
view: bool = False
add: bool = False
delete: bool = False
safe_edit: bool = False
edit_bells: bool = False
edit_clock: bool = False
edit_warranty: bool = False
full_edit: bool = False
control: bool = False
class AppUsersPermissions(BaseModel):
view: bool = False
add: bool = False
delete: bool = False
safe_edit: bool = False
full_edit: bool = False
class IssuesNotesPermissions(BaseModel):
view: bool = False
add: bool = False
delete: bool = False
edit: bool = False
class MailPermissions(BaseModel):
view: bool = False
compose: bool = False
reply: bool = False
class CrmPermissions(BaseModel):
activity_log: bool = False
class CrmCustomersPermissions(BaseModel):
full_access: bool = False
overview: bool = False
orders_view: bool = False
orders_edit: bool = False
quotations_view: bool = False
quotations_edit: bool = False
comms_view: bool = False
comms_log: bool = False
comms_edit: bool = False
comms_compose: bool = False
add: bool = False
delete: bool = False
files_view: bool = False
files_edit: bool = False
devices_view: bool = False
devices_edit: bool = False
class CrmProductsPermissions(BaseModel):
view: bool = False view: bool = False
add: bool = False add: bool = False
edit: bool = False edit: bool = False
delete: bool = False
class MfgPermissions(BaseModel):
view_inventory: bool = False
edit: bool = False
provision: bool = False
firmware_view: bool = False
firmware_edit: bool = False
class ApiReferencePermissions(BaseModel):
access: bool = False
class MqttPermissions(BaseModel):
access: bool = False
class StaffPermissions(BaseModel): class StaffPermissions(BaseModel):
melodies: SectionPermissions = SectionPermissions() melodies: MelodiesPermissions = MelodiesPermissions()
devices: SectionPermissions = SectionPermissions() devices: DevicesPermissions = DevicesPermissions()
app_users: SectionPermissions = SectionPermissions() app_users: AppUsersPermissions = AppUsersPermissions()
equipment: SectionPermissions = SectionPermissions() issues_notes: IssuesNotesPermissions = IssuesNotesPermissions()
manufacturing: SectionPermissions = SectionPermissions() mail: MailPermissions = MailPermissions()
mqtt: bool = False crm: CrmPermissions = CrmPermissions()
crm_customers: CrmCustomersPermissions = CrmCustomersPermissions()
crm_products: CrmProductsPermissions = CrmProductsPermissions()
mfg: MfgPermissions = MfgPermissions()
api_reference: ApiReferencePermissions = ApiReferencePermissions()
mqtt: MqttPermissions = MqttPermissions()
# Default permissions per role
def default_permissions_for_role(role: str) -> Optional[dict]: def default_permissions_for_role(role: str) -> Optional[dict]:
if role in ("sysadmin", "admin"): if role in ("sysadmin", "admin"):
return None # Full access, permissions field not used return None # Full access, permissions field not used
full = {"view": True, "add": True, "edit": True, "delete": True}
view_only = {"view": True, "add": False, "edit": False, "delete": False}
if role == "editor": if role == "editor":
return { return {
"melodies": full, "melodies": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True, "archetype_access": True, "settings_access": True, "compose_access": True},
"devices": full, "devices": {"view": True, "add": True, "delete": True, "safe_edit": True, "edit_bells": True, "edit_clock": True, "edit_warranty": True, "full_edit": True, "control": True},
"app_users": full, "app_users": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True},
"equipment": full, "issues_notes": {"view": True, "add": True, "delete": True, "edit": True},
"manufacturing": view_only, "mail": {"view": True, "compose": True, "reply": True},
"mqtt": True, "crm": {"activity_log": True},
"crm_customers": {"full_access": True, "overview": True, "orders_view": True, "orders_edit": True, "quotations_view": True, "quotations_edit": True, "comms_view": True, "comms_log": True, "comms_edit": True, "comms_compose": True, "add": True, "delete": True, "files_view": True, "files_edit": True, "devices_view": True, "devices_edit": True},
"crm_products": {"view": True, "add": True, "edit": True},
"mfg": {"view_inventory": True, "edit": True, "provision": True, "firmware_view": True, "firmware_edit": True},
"api_reference": {"access": True},
"mqtt": {"access": True},
} }
# user role - view only # user role - view only
return { return {
"melodies": view_only, "melodies": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False, "archetype_access": False, "settings_access": False, "compose_access": False},
"devices": view_only, "devices": {"view": True, "add": False, "delete": False, "safe_edit": False, "edit_bells": False, "edit_clock": False, "edit_warranty": False, "full_edit": False, "control": False},
"app_users": view_only, "app_users": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False},
"equipment": view_only, "issues_notes": {"view": True, "add": False, "delete": False, "edit": False},
"manufacturing": {"view": False, "add": False, "edit": False, "delete": False}, "mail": {"view": True, "compose": False, "reply": False},
"mqtt": False, "crm": {"activity_log": False},
"crm_customers": {"full_access": False, "overview": True, "orders_view": True, "orders_edit": False, "quotations_view": True, "quotations_edit": False, "comms_view": True, "comms_log": False, "comms_edit": False, "comms_compose": False, "add": False, "delete": False, "files_view": True, "files_edit": False, "devices_view": True, "devices_edit": False},
"crm_products": {"view": True, "add": False, "edit": False},
"mfg": {"view_inventory": True, "edit": False, "provision": False, "firmware_view": True, "firmware_edit": False},
"api_reference": {"access": False},
"mqtt": {"access": False},
} }

View File

@@ -1,5 +1,5 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import List from typing import List, Dict, Any
import json import json
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
mqtt_admin_password: str = "" mqtt_admin_password: str = ""
mqtt_secret: str = "change-me-in-production" mqtt_secret: str = "change-me-in-production"
mosquitto_password_file: str = "/etc/mosquitto/passwd" mosquitto_password_file: str = "/etc/mosquitto/passwd"
mqtt_client_id: str = "bellsystems-admin-panel"
# SQLite (MQTT data storage) # SQLite (MQTT data storage)
sqlite_db_path: str = "./mqtt_data.db" sqlite_db_path: str = "./mqtt_data.db"
@@ -37,6 +38,30 @@ class Settings(BaseSettings):
backend_cors_origins: str = '["http://localhost:5173"]' backend_cors_origins: str = '["http://localhost:5173"]'
debug: bool = True debug: bool = True
# Nextcloud WebDAV
nextcloud_url: str = ""
nextcloud_username: str = "" # WebDAV login & URL path username
nextcloud_password: str = "" # Use an app password for better security
nextcloud_dav_user: str = "" # Override URL path username if different from login
nextcloud_base_path: str = "BellSystems"
# IMAP/SMTP Email
imap_host: str = ""
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
imap_use_ssl: bool = True
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = True
email_sync_interval_minutes: int = 15
# Multi-mailbox config (JSON array). If empty, legacy single-account IMAP/SMTP is used.
# Example item:
# {"key":"sales","label":"Sales","email":"sales@bellsystems.gr","imap_host":"...","imap_username":"...","imap_password":"...","smtp_host":"...","smtp_username":"...","smtp_password":"...","sync_inbound":true,"allow_send":true}
mail_accounts_json: str = "[]"
# Auto-deploy (Gitea webhook) # Auto-deploy (Gitea webhook)
deploy_secret: str = "" deploy_secret: str = ""
deploy_project_path: str = "/app" deploy_project_path: str = "/app"
@@ -45,6 +70,14 @@ class Settings(BaseSettings):
def cors_origins(self) -> List[str]: def cors_origins(self) -> List[str]:
return json.loads(self.backend_cors_origins) return json.loads(self.backend_cors_origins)
@property
def mail_accounts(self) -> List[Dict[str, Any]]:
try:
raw = json.loads(self.mail_accounts_json or "[]")
return raw if isinstance(raw, list) else []
except Exception:
return []
model_config = {"env_file": ".env", "extra": "ignore"} model_config = {"env_file": ".env", "extra": "ignore"}

0
backend/crm/__init__.py Normal file
View File

417
backend/crm/comms_router.py Normal file
View File

@@ -0,0 +1,417 @@
import base64
import json
from fastapi import APIRouter, Depends, HTTPException, Query, Form, File, UploadFile
from pydantic import BaseModel
from typing import List, Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from config import settings
from crm.models import CommCreate, CommUpdate, CommInDB, CommListResponse, MediaCreate, MediaDirection
from crm import service
from crm import email_sync
from crm.mail_accounts import get_mail_accounts
router = APIRouter(prefix="/api/crm/comms", tags=["crm-comms"])
class EmailSendResponse(BaseModel):
entry: dict
class EmailSyncResponse(BaseModel):
new_count: int
class MailListResponse(BaseModel):
entries: list
total: int
@router.get("/all", response_model=CommListResponse)
async def list_all_comms(
type: Optional[str] = Query(None),
direction: Optional[str] = Query(None),
limit: int = Query(200, le=500),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
entries = await service.list_all_comms(type=type, direction=direction, limit=limit)
return CommListResponse(entries=entries, total=len(entries))
@router.get("", response_model=CommListResponse)
async def list_comms(
customer_id: str = Query(...),
type: Optional[str] = Query(None),
direction: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
entries = await service.list_comms(customer_id=customer_id, type=type, direction=direction)
return CommListResponse(entries=entries, total=len(entries))
@router.post("", response_model=CommInDB, status_code=201)
async def create_comm(
body: CommCreate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.create_comm(body)
@router.get("/email/all", response_model=MailListResponse)
async def list_all_emails(
direction: Optional[str] = Query(None),
customers_only: bool = Query(False),
mailbox: Optional[str] = Query(None, description="sales|support|both|all or account key"),
limit: int = Query(500, le=1000),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""Return all email comms (all senders + unmatched), for the Mail page."""
selected_accounts = None
if mailbox and mailbox not in {"all", "both"}:
if mailbox == "sales":
selected_accounts = ["sales"]
elif mailbox == "support":
selected_accounts = ["support"]
else:
selected_accounts = [mailbox]
entries = await service.list_all_emails(
direction=direction,
customers_only=customers_only,
mail_accounts=selected_accounts,
limit=limit,
)
return MailListResponse(entries=entries, total=len(entries))
@router.get("/email/accounts")
async def list_mail_accounts(
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
accounts = get_mail_accounts()
return {
"accounts": [
{
"key": a["key"],
"label": a["label"],
"email": a["email"],
"sync_inbound": bool(a.get("sync_inbound")),
"allow_send": bool(a.get("allow_send")),
}
for a in accounts
]
}
@router.get("/email/check")
async def check_new_emails(
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""Lightweight check: returns how many emails are on the server vs. stored locally."""
return await email_sync.check_new_emails()
# Email endpoints — must be before /{comm_id} wildcard routes
@router.post("/email/send", response_model=EmailSendResponse)
async def send_email_endpoint(
customer_id: Optional[str] = Form(None),
from_account: Optional[str] = Form(None),
to: str = Form(...),
subject: str = Form(...),
body: str = Form(...),
body_html: str = Form(""),
cc: str = Form("[]"), # JSON-encoded list of strings
files: List[UploadFile] = File(default=[]),
user: TokenPayload = Depends(require_permission("crm", "edit")),
):
if not get_mail_accounts():
raise HTTPException(status_code=503, detail="SMTP not configured")
try:
cc_list: List[str] = json.loads(cc) if cc else []
except Exception:
cc_list = []
# Read all uploaded files into memory
file_attachments = []
for f in files:
content = await f.read()
mime_type = f.content_type or "application/octet-stream"
file_attachments.append((f.filename, content, mime_type))
from crm.email_sync import send_email
try:
entry = await send_email(
customer_id=customer_id or None,
from_account=from_account,
to=to,
subject=subject,
body=body,
body_html=body_html,
cc=cc_list,
sent_by=user.name or user.sub,
file_attachments=file_attachments if file_attachments else None,
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
return EmailSendResponse(entry=entry)
@router.post("/email/sync", response_model=EmailSyncResponse)
async def sync_email_endpoint(
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
if not get_mail_accounts():
raise HTTPException(status_code=503, detail="IMAP not configured")
from crm.email_sync import sync_emails
new_count = await sync_emails()
return EmailSyncResponse(new_count=new_count)
class SaveInlineRequest(BaseModel):
data_uri: str
filename: str
subfolder: str = "received_media"
mime_type: Optional[str] = None
async def _resolve_customer_folder(customer_id: str) -> str:
"""Return the Nextcloud folder_id for a customer (falls back to customer_id)."""
from shared.firebase import get_db as get_firestore
firestore_db = get_firestore()
doc = firestore_db.collection("crm_customers").document(customer_id).get()
if not doc.exists:
raise HTTPException(status_code=404, detail="Customer not found")
data = doc.to_dict()
return data.get("folder_id") or customer_id
async def _upload_to_nc(folder_id: str, subfolder: str, filename: str,
content: bytes, mime_type: str, customer_id: str,
uploaded_by: str, tags: list[str]) -> dict:
from crm import nextcloud
target_folder = f"customers/{folder_id}/{subfolder}"
file_path = f"{target_folder}/{filename}"
await nextcloud.ensure_folder(target_folder)
await nextcloud.upload_file(file_path, content, mime_type)
media = await service.create_media(MediaCreate(
customer_id=customer_id,
filename=filename,
nextcloud_path=file_path,
mime_type=mime_type,
direction=MediaDirection.received,
tags=tags,
uploaded_by=uploaded_by,
))
return {"ok": True, "media_id": media.id, "nextcloud_path": file_path}
@router.post("/email/{comm_id}/save-inline")
async def save_email_inline_image(
comm_id: str,
body: SaveInlineRequest,
user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""Save an inline image (data-URI from email HTML body) to Nextcloud."""
comm = await service.get_comm(comm_id)
customer_id = comm.customer_id
if not customer_id:
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
folder_id = await _resolve_customer_folder(customer_id)
# Parse data URI
data_uri = body.data_uri
mime_type = body.mime_type or "image/png"
if "," in data_uri:
header, encoded = data_uri.split(",", 1)
try:
mime_type = header.split(":")[1].split(";")[0]
except Exception:
pass
else:
encoded = data_uri
try:
content = base64.b64decode(encoded)
except Exception:
raise HTTPException(status_code=400, detail="Invalid base64 data")
return await _upload_to_nc(
folder_id, body.subfolder, body.filename,
content, mime_type, customer_id,
user.name or user.sub, ["email-inline-image"],
)
@router.post("/email/{comm_id}/save-attachment/{attachment_index}")
async def save_email_attachment(
comm_id: str,
attachment_index: int,
filename: str = Form(...),
subfolder: str = Form("received_media"),
user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Re-fetch a specific attachment from IMAP (by index in the email's attachment list)
and save it to the customer's Nextcloud media folder.
"""
import asyncio
comm = await service.get_comm(comm_id)
customer_id = comm.customer_id
if not customer_id:
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
ext_message_id = comm.ext_message_id
if not ext_message_id:
raise HTTPException(status_code=400, detail="No message ID stored for this email")
attachments_meta = comm.attachments or []
if attachment_index < 0 or attachment_index >= len(attachments_meta):
raise HTTPException(status_code=400, detail="Attachment index out of range")
att_meta = attachments_meta[attachment_index]
mime_type = att_meta.content_type or "application/octet-stream"
from crm.mail_accounts import account_by_key, account_by_email
account = account_by_key(comm.mail_account) or account_by_email(comm.from_addr)
if not account:
raise HTTPException(status_code=400, detail="Email account config not found for this message")
# Re-fetch from IMAP in executor
def _fetch_attachment():
import imaplib, email as _email
if account.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
else:
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
imap.login(account["imap_username"], account["imap_password"])
imap.select(account.get("imap_inbox", "INBOX"))
# Search by Message-ID header
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
uids = data[0].split() if data[0] else []
if not uids:
raise ValueError(f"Message not found on IMAP server: {ext_message_id}")
_, msg_data = imap.fetch(uids[0], "(RFC822)")
raw = msg_data[0][1]
msg = _email.message_from_bytes(raw)
imap.logout()
# Walk attachments in order — find the one at attachment_index
found_idx = 0
for part in msg.walk():
cd = str(part.get("Content-Disposition", ""))
if "attachment" not in cd:
continue
if found_idx == attachment_index:
payload = part.get_payload(decode=True)
if payload is None:
raise ValueError("Attachment payload is empty")
return payload
found_idx += 1
raise ValueError(f"Attachment index {attachment_index} not found in message")
loop = asyncio.get_event_loop()
try:
content = await loop.run_in_executor(None, _fetch_attachment)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP fetch failed: {e}")
folder_id = await _resolve_customer_folder(customer_id)
return await _upload_to_nc(
folder_id, subfolder, filename,
content, mime_type, customer_id,
user.name or user.sub, ["email-attachment"],
)
class BulkDeleteRequest(BaseModel):
ids: List[str]
class ToggleImportantRequest(BaseModel):
important: bool
class ToggleReadRequest(BaseModel):
read: bool
@router.post("/bulk-delete", status_code=200)
async def bulk_delete_comms(
body: BulkDeleteRequest,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
# Try remote IMAP delete for email rows first (best-effort), then local delete.
for comm_id in body.ids:
try:
comm = await service.get_comm(comm_id)
if comm.type == "email" and comm.ext_message_id:
await email_sync.delete_remote_email(
comm.ext_message_id,
comm.mail_account,
comm.from_addr,
)
except Exception:
# Keep delete resilient; local delete still proceeds.
pass
count = await service.delete_comms_bulk(body.ids)
return {"deleted": count}
@router.patch("/{comm_id}/important", response_model=CommInDB)
async def set_comm_important(
comm_id: str,
body: ToggleImportantRequest,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.set_comm_important(comm_id, body.important)
@router.patch("/{comm_id}/read", response_model=CommInDB)
async def set_comm_read(
comm_id: str,
body: ToggleReadRequest,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
try:
comm = await service.get_comm(comm_id)
if comm.type == "email" and comm.ext_message_id:
await email_sync.set_remote_read(
comm.ext_message_id,
comm.mail_account,
comm.from_addr,
body.read,
)
except Exception:
pass
return await service.set_comm_read(comm_id, body.read)
@router.put("/{comm_id}", response_model=CommInDB)
async def update_comm(
comm_id: str,
body: CommUpdate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.update_comm(comm_id, body)
@router.delete("/{comm_id}", status_code=204)
async def delete_comm(
comm_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
try:
comm = await service.get_comm(comm_id)
if comm.type == "email" and comm.ext_message_id:
await email_sync.delete_remote_email(
comm.ext_message_id,
comm.mail_account,
comm.from_addr,
)
except Exception:
pass
await service.delete_comm(comm_id)

View File

@@ -0,0 +1,71 @@
import asyncio
import logging
from fastapi import APIRouter, Depends, Query, BackgroundTasks
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse
from crm import service, nextcloud
from config import settings
router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"])
logger = logging.getLogger(__name__)
@router.get("", response_model=CustomerListResponse)
def list_customers(
search: Optional[str] = Query(None),
tag: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
customers = service.list_customers(search=search, tag=tag)
return CustomerListResponse(customers=customers, total=len(customers))
@router.get("/{customer_id}", response_model=CustomerInDB)
def get_customer(
customer_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return service.get_customer(customer_id)
@router.post("", response_model=CustomerInDB, status_code=201)
async def create_customer(
body: CustomerCreate,
background_tasks: BackgroundTasks,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
customer = service.create_customer(body)
if settings.nextcloud_url:
background_tasks.add_task(_init_nextcloud_folder, customer)
return customer
async def _init_nextcloud_folder(customer) -> None:
try:
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
for sub in ("media", "documents", "sent", "received"):
await nextcloud.ensure_folder(f"{base}/{sub}")
await nextcloud.write_info_file(base, customer.name, customer.id)
except Exception as e:
logger.warning("Nextcloud folder init failed for customer %s: %s", customer.id, e)
@router.put("/{customer_id}", response_model=CustomerInDB)
def update_customer(
customer_id: str,
body: CustomerUpdate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return service.update_customer(customer_id, body)
@router.delete("/{customer_id}", status_code=204)
def delete_customer(
customer_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
service.delete_customer(customer_id)

837
backend/crm/email_sync.py Normal file
View File

@@ -0,0 +1,837 @@
"""
IMAP email sync and SMTP email send for CRM.
Uses only stdlib imaplib/smtplib — no extra dependencies.
Sync is run in an executor to avoid blocking the event loop.
"""
import asyncio
import base64
import email
import email.header
import email.utils
import html.parser
import imaplib
import json
import logging
import re
import smtplib
import uuid
from datetime import datetime, timezone
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from typing import List, Optional, Tuple
from config import settings
from mqtt import database as mqtt_db
from crm.mail_accounts import get_mail_accounts, account_by_key, account_by_email
logger = logging.getLogger("crm.email_sync")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _decode_header(raw: str) -> str:
"""Decode an RFC2047-encoded email header value."""
if not raw:
return ""
parts = email.header.decode_header(raw)
decoded = []
for part, enc in parts:
if isinstance(part, bytes):
decoded.append(part.decode(enc or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
class _HTMLStripper(html.parser.HTMLParser):
def __init__(self):
super().__init__()
self._text = []
def handle_data(self, data):
self._text.append(data)
def get_text(self):
return " ".join(self._text)
def _strip_html(html_str: str) -> str:
s = _HTMLStripper()
s.feed(html_str)
return s.get_text()
def _extract_inline_data_images(html_body: str) -> tuple[str, list[tuple[str, bytes, str]]]:
"""Replace data-URI images in HTML with cid: references and return inline parts.
Returns: (new_html, [(cid, image_bytes, mime_type), ...])
"""
if not html_body:
return "", []
inline_parts: list[tuple[str, bytes, str]] = []
seen: dict[str, str] = {} # data-uri -> cid
src_pattern = re.compile(r"""src=(['"])(data:image/[^'"]+)\1""", re.IGNORECASE)
data_pattern = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL)
def _replace(match: re.Match) -> str:
quote = match.group(1)
data_uri = match.group(2)
if data_uri in seen:
cid = seen[data_uri]
return f"src={quote}cid:{cid}{quote}"
parsed = data_pattern.match(data_uri)
if not parsed:
return match.group(0)
mime_type = parsed.group(1).lower()
b64_data = parsed.group(2).strip()
try:
payload = base64.b64decode(b64_data, validate=False)
except Exception:
return match.group(0)
cid = f"inline-{uuid.uuid4().hex}"
seen[data_uri] = cid
inline_parts.append((cid, payload, mime_type))
return f"src={quote}cid:{cid}{quote}"
return src_pattern.sub(_replace, html_body), inline_parts
def _load_customer_email_map() -> dict[str, str]:
"""Build a lookup of customer email -> customer_id from Firestore."""
from shared.firebase import get_db as get_firestore
firestore_db = get_firestore()
addr_to_customer: dict[str, str] = {}
for doc in firestore_db.collection("crm_customers").stream():
data = doc.to_dict() or {}
for contact in (data.get("contacts") or []):
if contact.get("type") == "email" and contact.get("value"):
addr_to_customer[str(contact["value"]).strip().lower()] = doc.id
return addr_to_customer
def _get_body(msg: email.message.Message) -> tuple[str, str]:
"""Extract (plain_text, html_body) from an email message.
Inline images (cid: references) are substituted with data-URIs so they
render correctly in a sandboxed iframe without external requests.
"""
import base64 as _b64
plain = None
html_body = None
# Map Content-ID → data-URI for inline images
cid_map: dict[str, str] = {}
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
cid = part.get("Content-ID", "").strip().strip("<>")
if "attachment" in cd:
continue
if ct == "text/plain" and plain is None:
raw = part.get_payload(decode=True)
charset = part.get_content_charset() or "utf-8"
plain = raw.decode(charset, errors="replace")
elif ct == "text/html" and html_body is None:
raw = part.get_payload(decode=True)
charset = part.get_content_charset() or "utf-8"
html_body = raw.decode(charset, errors="replace")
elif ct.startswith("image/") and cid:
raw = part.get_payload(decode=True)
if raw:
b64 = _b64.b64encode(raw).decode("ascii")
cid_map[cid] = f"data:{ct};base64,{b64}"
else:
ct = msg.get_content_type()
payload = msg.get_payload(decode=True)
charset = msg.get_content_charset() or "utf-8"
if payload:
text = payload.decode(charset, errors="replace")
if ct == "text/plain":
plain = text
elif ct == "text/html":
html_body = text
# Substitute cid: references with data-URIs
if html_body and cid_map:
for cid, data_uri in cid_map.items():
html_body = html_body.replace(f"cid:{cid}", data_uri)
plain_text = (plain or (html_body and _strip_html(html_body)) or "").strip()
return plain_text, (html_body or "").strip()
def _get_attachments(msg: email.message.Message) -> list[dict]:
"""Extract attachment info (filename, content_type, size) without storing content."""
attachments = []
if msg.is_multipart():
for part in msg.walk():
cd = str(part.get("Content-Disposition", ""))
if "attachment" in cd:
filename = part.get_filename() or "attachment"
filename = _decode_header(filename)
ct = part.get_content_type() or "application/octet-stream"
payload = part.get_payload(decode=True)
size = len(payload) if payload else 0
attachments.append({"filename": filename, "content_type": ct, "size": size})
return attachments
# ---------------------------------------------------------------------------
# IMAP sync (synchronous — called via run_in_executor)
# ---------------------------------------------------------------------------
def _sync_account_emails_sync(account: dict) -> tuple[list[dict], bool]:
if not account.get("imap_host") or not account.get("imap_username") or not account.get("imap_password"):
return [], False
if account.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
else:
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
imap.login(account["imap_username"], account["imap_password"])
# readonly=True prevents marking messages as \Seen while syncing.
imap.select(account.get("imap_inbox", "INBOX"), readonly=True)
_, data = imap.search(None, "ALL")
uids = data[0].split() if data[0] else []
results = []
complete = True
for uid in uids:
try:
_, msg_data = imap.fetch(uid, "(FLAGS RFC822)")
meta = msg_data[0][0] if msg_data and isinstance(msg_data[0], tuple) else b""
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
message_id = msg.get("Message-ID", "").strip()
from_addr = email.utils.parseaddr(msg.get("From", ""))[1]
to_addrs_raw = msg.get("To", "")
to_addrs = [a for _, a in email.utils.getaddresses([to_addrs_raw])]
subject = _decode_header(msg.get("Subject", ""))
date_str = msg.get("Date", "")
try:
occurred_at = email.utils.parsedate_to_datetime(date_str).isoformat()
except Exception:
occurred_at = datetime.now(timezone.utc).isoformat()
is_read = b"\\Seen" in (meta or b"")
try:
body, body_html = _get_body(msg)
except Exception:
body, body_html = "", ""
try:
file_attachments = _get_attachments(msg)
except Exception:
file_attachments = []
results.append({
"mail_account": account["key"],
"message_id": message_id,
"from_addr": from_addr,
"to_addrs": to_addrs,
"subject": subject,
"body": body,
"body_html": body_html,
"attachments": file_attachments,
"occurred_at": occurred_at,
"is_read": bool(is_read),
})
except Exception as e:
complete = False
logger.warning(f"[EMAIL SYNC] Failed to parse message uid={uid} account={account['key']}: {e}")
imap.logout()
return results, complete
def _sync_emails_sync() -> tuple[list[dict], bool]:
all_msgs: list[dict] = []
all_complete = True
# Deduplicate by physical inbox source. Aliases often share the same mailbox.
seen_sources: set[tuple] = set()
for acc in get_mail_accounts():
if not acc.get("sync_inbound"):
continue
source = (
(acc.get("imap_host") or "").lower(),
int(acc.get("imap_port") or 0),
(acc.get("imap_username") or "").lower(),
(acc.get("imap_inbox") or "INBOX").upper(),
)
if source in seen_sources:
continue
seen_sources.add(source)
msgs, complete = _sync_account_emails_sync(acc)
all_msgs.extend(msgs)
all_complete = all_complete and complete
return all_msgs, all_complete
async def sync_emails() -> int:
"""
Pull emails from IMAP, match against CRM customers, store new ones.
Returns count of new entries created.
"""
if not get_mail_accounts():
return 0
loop = asyncio.get_event_loop()
try:
messages, fetch_complete = await loop.run_in_executor(None, _sync_emails_sync)
except Exception as e:
logger.error(f"[EMAIL SYNC] IMAP connect/fetch failed: {e}")
raise
db = await mqtt_db.get_db()
# Load all customer email contacts into a flat lookup: email -> customer_id
addr_to_customer = _load_customer_email_map()
# Load already-synced message-ids from DB
rows = await db.execute_fetchall(
"SELECT id, ext_message_id, COALESCE(mail_account, '') as mail_account, direction, is_read, customer_id "
"FROM crm_comms_log WHERE type='email' AND ext_message_id IS NOT NULL"
)
known_map = {
(r[1], r[2] or ""): {
"id": r[0],
"direction": r[3],
"is_read": int(r[4] or 0),
"customer_id": r[5],
}
for r in rows
}
new_count = 0
now = datetime.now(timezone.utc).isoformat()
server_ids_by_account: dict[str, set[str]] = {}
# Global inbound IDs from server snapshot, used to avoid account-classification delete oscillation.
inbound_server_ids: set[str] = set()
accounts = get_mail_accounts()
accounts_by_email = {a["email"].lower(): a for a in accounts}
# Initialize tracked inbound accounts even if inbox is empty.
for a in accounts:
if a.get("sync_inbound"):
server_ids_by_account[a["key"]] = set()
for msg in messages:
mid = msg["message_id"]
fetch_account_key = (msg.get("mail_account") or "").strip().lower()
from_addr = msg["from_addr"].lower()
to_addrs = [a.lower() for a in msg["to_addrs"]]
sender_acc = accounts_by_email.get(from_addr)
if sender_acc:
direction = "outbound"
resolved_account_key = sender_acc["key"]
customer_addrs = to_addrs
else:
direction = "inbound"
target_acc = None
for addr in to_addrs:
if addr in accounts_by_email:
target_acc = accounts_by_email[addr]
break
resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key)
customer_addrs = [from_addr]
if target_acc and not target_acc.get("sync_inbound"):
# Ignore inbound for non-synced aliases (e.g. info/news).
continue
if direction == "inbound" and mid and resolved_account_key in server_ids_by_account:
server_ids_by_account[resolved_account_key].add(mid)
inbound_server_ids.add(mid)
# Find matching customer (may be None - we still store the email)
customer_id = None
for addr in customer_addrs:
if addr in addr_to_customer:
customer_id = addr_to_customer[addr]
break
if mid and (mid, resolved_account_key) in known_map:
existing = known_map[(mid, resolved_account_key)]
# Backfill customer linkage for rows created without customer_id.
if customer_id and not existing.get("customer_id"):
await db.execute(
"UPDATE crm_comms_log SET customer_id=? WHERE id=?",
(customer_id, existing["id"]),
)
# Existing inbound message: sync read/unread state from server.
if direction == "inbound":
server_read = 1 if msg.get("is_read") else 0
await db.execute(
"UPDATE crm_comms_log SET is_read=? "
"WHERE type='email' AND direction='inbound' AND ext_message_id=? AND mail_account=?",
(server_read, mid, resolved_account_key),
)
continue # already stored
attachments_json = json.dumps(msg.get("attachments") or [])
to_addrs_json = json.dumps(to_addrs)
entry_id = str(uuid.uuid4())
await db.execute(
"""INSERT INTO crm_comms_log
(id, customer_id, type, mail_account, direction, subject, body, body_html, attachments,
ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at, is_read)
VALUES (?, ?, 'email', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', ?, ?, ?)""",
(entry_id, customer_id, resolved_account_key, direction, msg["subject"], msg["body"],
msg.get("body_html", ""), attachments_json,
mid, from_addr, to_addrs_json, msg["occurred_at"], now, 1 if msg.get("is_read") else 0),
)
new_count += 1
# Mirror remote deletes based on global inbound message-id snapshot.
# To avoid transient IMAP inconsistency causing add/remove oscillation,
# require two consecutive "missing" syncs before local deletion.
sync_keys = [a["key"] for a in accounts if a.get("sync_inbound")]
if sync_keys and fetch_complete:
placeholders = ",".join("?" for _ in sync_keys)
local_rows = await db.execute_fetchall(
f"SELECT id, ext_message_id, mail_account FROM crm_comms_log "
f"WHERE type='email' AND direction='inbound' AND mail_account IN ({placeholders}) "
"AND ext_message_id IS NOT NULL",
sync_keys,
)
to_delete: list[str] = []
for row in local_rows:
row_id, ext_id, acc_key = row[0], row[1], row[2]
if not ext_id:
continue
state_key = f"missing_email::{acc_key}::{ext_id}"
if ext_id in inbound_server_ids:
await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,))
continue
prev = await db.execute_fetchall("SELECT value FROM crm_sync_state WHERE key = ?", (state_key,))
prev_count = int(prev[0][0]) if prev and (prev[0][0] or "").isdigit() else 0
new_count = prev_count + 1
await db.execute(
"INSERT INTO crm_sync_state (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(state_key, str(new_count)),
)
if new_count >= 2:
to_delete.append(row_id)
await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,))
if to_delete:
del_ph = ",".join("?" for _ in to_delete)
await db.execute(f"DELETE FROM crm_comms_log WHERE id IN ({del_ph})", to_delete)
if new_count or server_ids_by_account:
await db.commit()
# Update last sync time
await db.execute(
"INSERT INTO crm_sync_state (key, value) VALUES ('last_email_sync', ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(now,),
)
await db.commit()
logger.info(f"[EMAIL SYNC] Done — {new_count} new emails stored")
return new_count
# ---------------------------------------------------------------------------
# Lightweight new-mail check (synchronous — called via run_in_executor)
# ---------------------------------------------------------------------------
def _check_server_count_sync() -> int:
# Keep this for backward compatibility; no longer used by check_new_emails().
total = 0
seen_sources: set[tuple] = set()
for acc in get_mail_accounts():
if not acc.get("sync_inbound"):
continue
source = (
(acc.get("imap_host") or "").lower(),
int(acc.get("imap_port") or 0),
(acc.get("imap_username") or "").lower(),
(acc.get("imap_inbox") or "INBOX").upper(),
)
if source in seen_sources:
continue
seen_sources.add(source)
if acc.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(acc["imap_host"], int(acc["imap_port"]))
else:
imap = imaplib.IMAP4(acc["imap_host"], int(acc["imap_port"]))
imap.login(acc["imap_username"], acc["imap_password"])
imap.select(acc.get("imap_inbox", "INBOX"), readonly=True)
_, data = imap.search(None, "ALL")
total += len(data[0].split()) if data[0] else 0
imap.logout()
return total
async def check_new_emails() -> dict:
"""
Compare server message count vs. locally stored count.
Returns {"new_count": int} — does NOT download or store anything.
"""
if not get_mail_accounts():
return {"new_count": 0}
loop = asyncio.get_event_loop()
try:
# Reuse same account-resolution logic as sync to avoid false positives.
messages, _ = await loop.run_in_executor(None, _sync_emails_sync)
except Exception as e:
logger.warning(f"[EMAIL CHECK] IMAP check failed: {e}")
raise
accounts = get_mail_accounts()
accounts_by_email = {a["email"].lower(): a for a in accounts}
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT ext_message_id, COALESCE(mail_account, '') as mail_account FROM crm_comms_log "
"WHERE type='email' AND ext_message_id IS NOT NULL"
)
known_ids = {(r[0], r[1] or "") for r in rows}
new_count = 0
for msg in messages:
mid = (msg.get("message_id") or "").strip()
if not mid:
continue
fetch_account_key = (msg.get("mail_account") or "").strip().lower()
from_addr = (msg.get("from_addr") or "").lower()
to_addrs = [(a or "").lower() for a in (msg.get("to_addrs") or [])]
sender_acc = accounts_by_email.get(from_addr)
if sender_acc:
# Outbound copy in mailbox; not part of "new inbound mail" banner.
continue
target_acc = None
for addr in to_addrs:
if addr in accounts_by_email:
target_acc = accounts_by_email[addr]
break
resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key)
if target_acc and not target_acc.get("sync_inbound"):
continue
if (mid, resolved_account_key) not in known_ids:
new_count += 1
return {"new_count": new_count}
# ---------------------------------------------------------------------------
# SMTP send (synchronous — called via run_in_executor)
# ---------------------------------------------------------------------------
def _append_to_sent_sync(account: dict, raw_message: bytes) -> None:
"""Best-effort append of sent MIME message to IMAP Sent folder."""
if not raw_message:
return
try:
if account.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
else:
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
imap.login(account["imap_username"], account["imap_password"])
preferred = str(account.get("imap_sent") or "Sent").strip() or "Sent"
candidates = [preferred, "Sent", "INBOX.Sent", "Sent Items", "INBOX.Sent Items"]
seen = set()
ordered_candidates = []
for name in candidates:
key = name.lower()
if key not in seen:
seen.add(key)
ordered_candidates.append(name)
appended = False
for mailbox in ordered_candidates:
try:
status, _ = imap.append(mailbox, "\\Seen", None, raw_message)
if status == "OK":
appended = True
break
except Exception:
continue
if not appended:
logger.warning("[EMAIL SEND] Sent copy append failed for account=%s", account.get("key"))
imap.logout()
except Exception as e:
logger.warning("[EMAIL SEND] IMAP append to Sent failed for account=%s: %s", account.get("key"), e)
def _send_email_sync(
account: dict,
to: str,
subject: str,
body: str,
body_html: str,
cc: List[str],
file_attachments: Optional[List[Tuple[str, bytes, str]]] = None,
) -> str:
"""Send via SMTP. Returns the Message-ID header.
file_attachments: list of (filename, content_bytes, mime_type)
"""
html_with_cids, inline_images = _extract_inline_data_images(body_html or "")
# Build body tree:
# - with inline images: related(alternative(text/plain, text/html), image parts)
# - without inline images: alternative(text/plain, text/html)
if inline_images:
body_part = MIMEMultipart("related")
alt_part = MIMEMultipart("alternative")
alt_part.attach(MIMEText(body, "plain", "utf-8"))
if html_with_cids:
alt_part.attach(MIMEText(html_with_cids, "html", "utf-8"))
body_part.attach(alt_part)
for idx, (cid, content, mime_type) in enumerate(inline_images, start=1):
maintype, _, subtype = mime_type.partition("/")
img_part = MIMEBase(maintype or "image", subtype or "png")
img_part.set_payload(content)
encoders.encode_base64(img_part)
img_part.add_header("Content-ID", f"<{cid}>")
img_part.add_header("Content-Disposition", "inline", filename=f"inline-{idx}.{subtype or 'png'}")
body_part.attach(img_part)
else:
body_part = MIMEMultipart("alternative")
body_part.attach(MIMEText(body, "plain", "utf-8"))
if body_html:
body_part.attach(MIMEText(body_html, "html", "utf-8"))
# Wrap with mixed only when classic file attachments exist.
if file_attachments:
msg = MIMEMultipart("mixed")
msg.attach(body_part)
else:
msg = body_part
from_addr = account["email"]
msg["From"] = from_addr
msg["To"] = to
msg["Subject"] = subject
if cc:
msg["Cc"] = ", ".join(cc)
msg_id = f"<{uuid.uuid4()}@bellsystems>"
msg["Message-ID"] = msg_id
# Attach files
for filename, content, mime_type in (file_attachments or []):
maintype, _, subtype = mime_type.partition("/")
part = MIMEBase(maintype or "application", subtype or "octet-stream")
part.set_payload(content)
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(part)
recipients = [to] + cc
raw_for_append = msg.as_bytes()
if account.get("smtp_use_tls"):
server = smtplib.SMTP(account["smtp_host"], int(account["smtp_port"]))
server.starttls()
else:
server = smtplib.SMTP_SSL(account["smtp_host"], int(account["smtp_port"]))
server.login(account["smtp_username"], account["smtp_password"])
server.sendmail(from_addr, recipients, msg.as_string())
server.quit()
_append_to_sent_sync(account, raw_for_append)
return msg_id
async def send_email(
customer_id: str | None,
from_account: str | None,
to: str,
subject: str,
body: str,
body_html: str,
cc: List[str],
sent_by: str,
file_attachments: Optional[List[Tuple[str, bytes, str]]] = None,
) -> dict:
"""Send an email and record it in crm_comms_log. Returns the new log entry.
file_attachments: list of (filename, content_bytes, mime_type)
"""
accounts = get_mail_accounts()
if not accounts:
raise RuntimeError("SMTP not configured")
account = account_by_key(from_account) if from_account else None
if not account:
raise RuntimeError("Please select a valid sender account")
if not account.get("allow_send"):
raise RuntimeError("Selected account is not allowed to send")
if not account.get("smtp_host") or not account.get("smtp_username") or not account.get("smtp_password"):
raise RuntimeError("SMTP not configured for selected account")
# If the caller did not provide a customer_id (e.g. compose from Mail page),
# auto-link by matching recipient addresses against CRM customer emails.
resolved_customer_id = customer_id
if not resolved_customer_id:
addr_to_customer = _load_customer_email_map()
rcpts = [to, *cc]
parsed_rcpts = [addr for _, addr in email.utils.getaddresses(rcpts) if addr]
for addr in parsed_rcpts:
key = (addr or "").strip().lower()
if key in addr_to_customer:
resolved_customer_id = addr_to_customer[key]
break
loop = asyncio.get_event_loop()
import functools
msg_id = await loop.run_in_executor(
None,
functools.partial(_send_email_sync, account, to, subject, body, body_html, cc, file_attachments or []),
)
# Upload attachments to Nextcloud and register in crm_media
comm_attachments = []
if file_attachments and resolved_customer_id:
from crm import nextcloud, service
from crm.models import MediaCreate, MediaDirection
from shared.firebase import get_db as get_firestore
firestore_db = get_firestore()
doc = firestore_db.collection("crm_customers").document(resolved_customer_id).get()
if doc.exists:
data = doc.to_dict()
# Build a minimal CustomerInDB-like object for get_customer_nc_path
folder_id = data.get("folder_id") or resolved_customer_id
nc_path = folder_id
for filename, content, mime_type in file_attachments:
# images/video → sent_media, everything else → documents
if mime_type.startswith("image/") or mime_type.startswith("video/"):
subfolder = "sent_media"
else:
subfolder = "documents"
target_folder = f"customers/{nc_path}/{subfolder}"
file_path = f"{target_folder}/{filename}"
try:
await nextcloud.ensure_folder(target_folder)
await nextcloud.upload_file(file_path, content, mime_type)
await service.create_media(MediaCreate(
customer_id=resolved_customer_id,
filename=filename,
nextcloud_path=file_path,
mime_type=mime_type,
direction=MediaDirection.sent,
tags=["email-attachment"],
uploaded_by=sent_by,
))
comm_attachments.append({"filename": filename, "nextcloud_path": file_path})
except Exception as e:
logger.warning(f"[EMAIL SEND] Failed to upload attachment {filename}: {e}")
now = datetime.now(timezone.utc).isoformat()
entry_id = str(uuid.uuid4())
db = await mqtt_db.get_db()
our_addr = account["email"].lower()
to_addrs_json = json.dumps([to] + cc)
attachments_json = json.dumps(comm_attachments)
await db.execute(
"""INSERT INTO crm_comms_log
(id, customer_id, type, mail_account, direction, subject, body, body_html, attachments,
ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at)
VALUES (?, ?, 'email', ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(entry_id, resolved_customer_id, account["key"], subject, body, body_html, attachments_json, msg_id,
our_addr, to_addrs_json, sent_by, now, now),
)
await db.commit()
return {
"id": entry_id,
"customer_id": resolved_customer_id,
"type": "email",
"mail_account": account["key"],
"direction": "outbound",
"subject": subject,
"body": body,
"body_html": body_html,
"attachments": comm_attachments,
"ext_message_id": msg_id,
"from_addr": our_addr,
"to_addrs": [to] + cc,
"logged_by": sent_by,
"occurred_at": now,
"created_at": now,
}
def _delete_remote_email_sync(account: dict, ext_message_id: str) -> bool:
if not ext_message_id:
return False
if account.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
else:
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
imap.login(account["imap_username"], account["imap_password"])
imap.select(account.get("imap_inbox", "INBOX"))
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
uids = data[0].split() if data and data[0] else []
if not uids:
imap.logout()
return False
for uid in uids:
imap.store(uid, "+FLAGS", "\\Deleted")
imap.expunge()
imap.logout()
return True
async def delete_remote_email(ext_message_id: str, mail_account: str | None, from_addr: str | None = None) -> bool:
account = account_by_key(mail_account) if mail_account else None
if not account:
account = account_by_email(from_addr)
if not account or not account.get("imap_host"):
return False
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(None, lambda: _delete_remote_email_sync(account, ext_message_id))
except Exception as e:
logger.warning(f"[EMAIL DELETE] Failed remote delete for {ext_message_id}: {e}")
return False
def _set_remote_read_sync(account: dict, ext_message_id: str, read: bool) -> bool:
if not ext_message_id:
return False
if account.get("imap_use_ssl"):
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
else:
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
imap.login(account["imap_username"], account["imap_password"])
imap.select(account.get("imap_inbox", "INBOX"))
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
uids = data[0].split() if data and data[0] else []
if not uids:
imap.logout()
return False
flag_op = "+FLAGS" if read else "-FLAGS"
for uid in uids:
imap.store(uid, flag_op, "\\Seen")
imap.logout()
return True
async def set_remote_read(ext_message_id: str, mail_account: str | None, from_addr: str | None, read: bool) -> bool:
account = account_by_key(mail_account) if mail_account else None
if not account:
account = account_by_email(from_addr)
if not account or not account.get("imap_host"):
return False
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(None, lambda: _set_remote_read_sync(account, ext_message_id, read))
except Exception as e:
logger.warning(f"[EMAIL READ] Failed remote read update for {ext_message_id}: {e}")
return False

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Any
from config import settings
def _bool(v: Any, default: bool) -> bool:
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() in {"1", "true", "yes", "on"}
if v is None:
return default
return bool(v)
def get_mail_accounts() -> list[dict]:
"""
Returns normalized account dictionaries.
Falls back to legacy single-account config if MAIL_ACCOUNTS_JSON is empty.
"""
configured = settings.mail_accounts
normalized: list[dict] = []
for idx, raw in enumerate(configured):
if not isinstance(raw, dict):
continue
key = str(raw.get("key") or "").strip().lower()
email = str(raw.get("email") or "").strip().lower()
if not key or not email:
continue
normalized.append(
{
"key": key,
"label": str(raw.get("label") or key.title()),
"email": email,
"imap_host": raw.get("imap_host") or settings.imap_host,
"imap_port": int(raw.get("imap_port") or settings.imap_port or 993),
"imap_username": raw.get("imap_username") or email,
"imap_password": raw.get("imap_password") or settings.imap_password,
"imap_use_ssl": _bool(raw.get("imap_use_ssl"), settings.imap_use_ssl),
"imap_inbox": str(raw.get("imap_inbox") or "INBOX"),
"imap_sent": str(raw.get("imap_sent") or "Sent"),
"smtp_host": raw.get("smtp_host") or settings.smtp_host,
"smtp_port": int(raw.get("smtp_port") or settings.smtp_port or 587),
"smtp_username": raw.get("smtp_username") or email,
"smtp_password": raw.get("smtp_password") or settings.smtp_password,
"smtp_use_tls": _bool(raw.get("smtp_use_tls"), settings.smtp_use_tls),
"sync_inbound": _bool(raw.get("sync_inbound"), True),
"allow_send": _bool(raw.get("allow_send"), True),
}
)
if normalized:
return normalized
# Legacy single-account fallback
if settings.imap_host or settings.smtp_host:
legacy_email = (settings.smtp_username or settings.imap_username or "").strip().lower()
if legacy_email:
return [
{
"key": "default",
"label": "Default",
"email": legacy_email,
"imap_host": settings.imap_host,
"imap_port": settings.imap_port,
"imap_username": settings.imap_username,
"imap_password": settings.imap_password,
"imap_use_ssl": settings.imap_use_ssl,
"imap_inbox": "INBOX",
"imap_sent": "Sent",
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password": settings.smtp_password,
"smtp_use_tls": settings.smtp_use_tls,
"sync_inbound": True,
"allow_send": True,
}
]
return []
def account_by_key(key: str | None) -> dict | None:
k = (key or "").strip().lower()
if not k:
return None
for acc in get_mail_accounts():
if acc["key"] == k:
return acc
return None
def account_by_email(email_addr: str | None) -> dict | None:
e = (email_addr or "").strip().lower()
if not e:
return None
for acc in get_mail_accounts():
if acc["email"] == e:
return acc
return None

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from crm.models import MediaCreate, MediaInDB, MediaListResponse
from crm import service
router = APIRouter(prefix="/api/crm/media", tags=["crm-media"])
@router.get("", response_model=MediaListResponse)
async def list_media(
customer_id: Optional[str] = Query(None),
order_id: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
items = await service.list_media(customer_id=customer_id, order_id=order_id)
return MediaListResponse(items=items, total=len(items))
@router.post("", response_model=MediaInDB, status_code=201)
async def create_media(
body: MediaCreate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.create_media(body)
@router.delete("/{media_id}", status_code=204)
async def delete_media(
media_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
await service.delete_media(media_id)

353
backend/crm/models.py Normal file
View File

@@ -0,0 +1,353 @@
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel
class ProductCategory(str, Enum):
controller = "controller"
striker = "striker"
clock = "clock"
part = "part"
repair_service = "repair_service"
class CostLineItem(BaseModel):
name: str
quantity: float = 1
price: float = 0.0
class ProductCosts(BaseModel):
labor_hours: Optional[float] = None
labor_rate: Optional[float] = None
items: List[CostLineItem] = []
total: Optional[float] = None
class ProductStock(BaseModel):
on_hand: int = 0
reserved: int = 0
available: int = 0
class ProductCreate(BaseModel):
name: str
sku: Optional[str] = None
category: ProductCategory
description: Optional[str] = None
price: float
currency: str = "EUR"
costs: Optional[ProductCosts] = None
stock: Optional[ProductStock] = None
active: bool = True
status: str = "active" # active | discontinued | planned
photo_url: Optional[str] = None
class ProductUpdate(BaseModel):
name: Optional[str] = None
sku: Optional[str] = None
category: Optional[ProductCategory] = None
description: Optional[str] = None
price: Optional[float] = None
currency: Optional[str] = None
costs: Optional[ProductCosts] = None
stock: Optional[ProductStock] = None
active: Optional[bool] = None
status: Optional[str] = None
photo_url: Optional[str] = None
class ProductInDB(ProductCreate):
id: str
created_at: str
updated_at: str
class ProductListResponse(BaseModel):
products: List[ProductInDB]
total: int
# ── Customers ────────────────────────────────────────────────────────────────
class ContactType(str, Enum):
email = "email"
phone = "phone"
whatsapp = "whatsapp"
other = "other"
class CustomerContact(BaseModel):
type: ContactType
label: str
value: str
primary: bool = False
class CustomerNote(BaseModel):
text: str
by: str
at: str
class OwnedItemType(str, Enum):
console_device = "console_device"
product = "product"
freetext = "freetext"
class OwnedItem(BaseModel):
type: OwnedItemType
# console_device fields
device_id: Optional[str] = None
label: Optional[str] = None
# product fields
product_id: Optional[str] = None
product_name: Optional[str] = None
quantity: Optional[int] = None
serial_numbers: Optional[List[str]] = None
# freetext fields
description: Optional[str] = None
serial_number: Optional[str] = None
notes: Optional[str] = None
class CustomerLocation(BaseModel):
city: Optional[str] = None
country: Optional[str] = None
region: Optional[str] = None
class CustomerCreate(BaseModel):
title: Optional[str] = None
name: str
surname: Optional[str] = None
organization: Optional[str] = None
contacts: List[CustomerContact] = []
notes: List[CustomerNote] = []
location: Optional[CustomerLocation] = None
language: str = "el"
tags: List[str] = []
owned_items: List[OwnedItem] = []
linked_user_ids: List[str] = []
nextcloud_folder: Optional[str] = None
folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu"
class CustomerUpdate(BaseModel):
title: Optional[str] = None
name: Optional[str] = None
surname: Optional[str] = None
organization: Optional[str] = None
contacts: Optional[List[CustomerContact]] = None
notes: Optional[List[CustomerNote]] = None
location: Optional[CustomerLocation] = None
language: Optional[str] = None
tags: Optional[List[str]] = None
owned_items: Optional[List[OwnedItem]] = None
linked_user_ids: Optional[List[str]] = None
nextcloud_folder: Optional[str] = None
# folder_id intentionally excluded from update — set once at creation
class CustomerInDB(CustomerCreate):
id: str
created_at: str
updated_at: str
class CustomerListResponse(BaseModel):
customers: List[CustomerInDB]
total: int
# ── Orders ───────────────────────────────────────────────────────────────────
class OrderStatus(str, Enum):
draft = "draft"
confirmed = "confirmed"
in_production = "in_production"
shipped = "shipped"
delivered = "delivered"
cancelled = "cancelled"
class PaymentStatus(str, Enum):
pending = "pending"
partial = "partial"
paid = "paid"
class OrderDiscount(BaseModel):
type: str # "percentage" | "fixed"
value: float = 0
reason: Optional[str] = None
class OrderShipping(BaseModel):
method: Optional[str] = None
tracking_number: Optional[str] = None
carrier: Optional[str] = None
shipped_at: Optional[str] = None
delivered_at: Optional[str] = None
destination: Optional[str] = None
class OrderItem(BaseModel):
type: str # console_device | product | freetext
product_id: Optional[str] = None
product_name: Optional[str] = None
description: Optional[str] = None
quantity: int = 1
unit_price: float = 0.0
serial_numbers: List[str] = []
class OrderCreate(BaseModel):
customer_id: str
order_number: Optional[str] = None
status: OrderStatus = OrderStatus.draft
items: List[OrderItem] = []
subtotal: float = 0
discount: Optional[OrderDiscount] = None
total_price: float = 0
currency: str = "EUR"
shipping: Optional[OrderShipping] = None
payment_status: PaymentStatus = PaymentStatus.pending
invoice_path: Optional[str] = None
notes: Optional[str] = None
class OrderUpdate(BaseModel):
customer_id: Optional[str] = None
order_number: Optional[str] = None
status: Optional[OrderStatus] = None
items: Optional[List[OrderItem]] = None
subtotal: Optional[float] = None
discount: Optional[OrderDiscount] = None
total_price: Optional[float] = None
currency: Optional[str] = None
shipping: Optional[OrderShipping] = None
payment_status: Optional[PaymentStatus] = None
invoice_path: Optional[str] = None
notes: Optional[str] = None
class OrderInDB(OrderCreate):
id: str
created_at: str
updated_at: str
class OrderListResponse(BaseModel):
orders: List[OrderInDB]
total: int
# ── Comms Log ─────────────────────────────────────────────────────────────────
class CommType(str, Enum):
email = "email"
whatsapp = "whatsapp"
call = "call"
sms = "sms"
note = "note"
in_person = "in_person"
class CommDirection(str, Enum):
inbound = "inbound"
outbound = "outbound"
internal = "internal"
class CommAttachment(BaseModel):
filename: str
nextcloud_path: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
class CommCreate(BaseModel):
customer_id: Optional[str] = None
type: CommType
mail_account: Optional[str] = None
direction: CommDirection
subject: Optional[str] = None
body: Optional[str] = None
body_html: Optional[str] = None
attachments: List[CommAttachment] = []
ext_message_id: Optional[str] = None
from_addr: Optional[str] = None
to_addrs: Optional[List[str]] = None
logged_by: Optional[str] = None
occurred_at: Optional[str] = None # defaults to now if not provided
class CommUpdate(BaseModel):
subject: Optional[str] = None
body: Optional[str] = None
occurred_at: Optional[str] = None
class CommInDB(BaseModel):
id: str
customer_id: Optional[str] = None
type: CommType
mail_account: Optional[str] = None
direction: CommDirection
subject: Optional[str] = None
body: Optional[str] = None
body_html: Optional[str] = None
attachments: List[CommAttachment] = []
ext_message_id: Optional[str] = None
from_addr: Optional[str] = None
to_addrs: Optional[List[str]] = None
logged_by: Optional[str] = None
occurred_at: str
created_at: str
is_important: bool = False
is_read: bool = False
class CommListResponse(BaseModel):
entries: List[CommInDB]
total: int
# ── Media ─────────────────────────────────────────────────────────────────────
class MediaDirection(str, Enum):
received = "received"
sent = "sent"
internal = "internal"
class MediaCreate(BaseModel):
customer_id: Optional[str] = None
order_id: Optional[str] = None
filename: str
nextcloud_path: str
mime_type: Optional[str] = None
direction: Optional[MediaDirection] = None
tags: List[str] = []
uploaded_by: Optional[str] = None
class MediaInDB(BaseModel):
id: str
customer_id: Optional[str] = None
order_id: Optional[str] = None
filename: str
nextcloud_path: str
mime_type: Optional[str] = None
direction: Optional[MediaDirection] = None
tags: List[str] = []
uploaded_by: Optional[str] = None
created_at: str
class MediaListResponse(BaseModel):
items: List[MediaInDB]
total: int

314
backend/crm/nextcloud.py Normal file
View File

@@ -0,0 +1,314 @@
"""
Nextcloud WebDAV client.
All paths passed to these functions are relative to `settings.nextcloud_base_path`.
The full WebDAV URL is:
{nextcloud_url}/remote.php/dav/files/{username}/{base_path}/{relative_path}
"""
import xml.etree.ElementTree as ET
from typing import List
from urllib.parse import unquote
import httpx
from fastapi import HTTPException
from config import settings
DAV_NS = "DAV:"
# Default timeout for all Nextcloud WebDAV requests (seconds)
_TIMEOUT = 60.0
# Shared async client — reuses TCP connections across requests so Nextcloud
# doesn't see rapid connection bursts that trigger brute-force throttling.
_http_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": "BellSystems-CP/1.0"},
)
return _http_client
async def close_client() -> None:
"""Close the shared HTTP client. Call this on application shutdown."""
global _http_client
if _http_client and not _http_client.is_closed:
await _http_client.aclose()
_http_client = None
async def keepalive_ping() -> None:
"""
Send a lightweight PROPFIND Depth:0 to the Nextcloud base folder to keep
the TCP connection alive. Safe to call even if Nextcloud is not configured.
"""
if not settings.nextcloud_url:
return
try:
url = _base_url()
client = _get_client()
await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
except Exception as e:
print(f"[NEXTCLOUD KEEPALIVE] ping failed: {e}")
def _dav_user() -> str:
"""The username used in the WebDAV URL path (may differ from the login username)."""
return settings.nextcloud_dav_user or settings.nextcloud_username
def _base_url() -> str:
if not settings.nextcloud_url:
raise HTTPException(status_code=503, detail="Nextcloud not configured")
return (
f"{settings.nextcloud_url.rstrip('/')}"
f"/remote.php/dav/files/{_dav_user()}"
f"/{settings.nextcloud_base_path}"
)
def _auth() -> tuple[str, str]:
return (settings.nextcloud_username, settings.nextcloud_password)
def _full_url(relative_path: str) -> str:
"""Build full WebDAV URL for a relative path."""
path = relative_path.strip("/")
base = _base_url()
return f"{base}/{path}" if path else base
def _parse_propfind(xml_bytes: bytes, base_path_prefix: str) -> List[dict]:
"""
Parse a PROPFIND XML response.
Returns list of file/folder entries, skipping the root itself.
"""
root = ET.fromstring(xml_bytes)
results = []
# The prefix we need to strip from D:href to get the relative path back
# href looks like: /remote.php/dav/files/user/BellSystems/Console/customers/abc/
dav_prefix = (
f"/remote.php/dav/files/{_dav_user()}"
f"/{settings.nextcloud_base_path}/"
)
for response in root.findall(f"{{{DAV_NS}}}response"):
href_el = response.find(f"{{{DAV_NS}}}href")
if href_el is None:
continue
href = unquote(href_el.text or "")
# Strip DAV prefix to get relative path within base_path
if href.startswith(dav_prefix):
rel = href[len(dav_prefix):].rstrip("/")
else:
rel = href
# Skip the folder itself (the root of the PROPFIND request)
if rel == base_path_prefix.strip("/"):
continue
propstat = response.find(f"{{{DAV_NS}}}propstat")
if propstat is None:
continue
prop = propstat.find(f"{{{DAV_NS}}}prop")
if prop is None:
continue
# is_dir: resourcetype contains D:collection
resource_type = prop.find(f"{{{DAV_NS}}}resourcetype")
is_dir = resource_type is not None and resource_type.find(f"{{{DAV_NS}}}collection") is not None
content_type_el = prop.find(f"{{{DAV_NS}}}getcontenttype")
mime_type = content_type_el.text if content_type_el is not None else (
"inode/directory" if is_dir else "application/octet-stream"
)
size_el = prop.find(f"{{{DAV_NS}}}getcontentlength")
size = int(size_el.text) if size_el is not None and size_el.text else 0
modified_el = prop.find(f"{{{DAV_NS}}}getlastmodified")
last_modified = modified_el.text if modified_el is not None else None
filename = rel.split("/")[-1] if rel else ""
results.append({
"filename": filename,
"path": rel,
"mime_type": mime_type,
"size": size,
"last_modified": last_modified,
"is_dir": is_dir,
})
return results
async def ensure_folder(relative_path: str) -> None:
"""
Create a folder (and all parents) in Nextcloud via MKCOL.
Includes the base_path segments so the full hierarchy is created from scratch.
Silently succeeds if folders already exist.
"""
# Build the complete path list: base_path segments + relative_path segments
base_parts = settings.nextcloud_base_path.strip("/").split("/")
rel_parts = relative_path.strip("/").split("/") if relative_path.strip("/") else []
all_parts = base_parts + rel_parts
dav_root = f"{settings.nextcloud_url.rstrip('/')}/remote.php/dav/files/{_dav_user()}"
client = _get_client()
built = ""
for part in all_parts:
built = f"{built}/{part}" if built else part
url = f"{dav_root}/{built}"
resp = await client.request("MKCOL", url, auth=_auth())
# 201 = created, 405/409 = already exists — both are fine
if resp.status_code not in (201, 405, 409):
raise HTTPException(
status_code=502,
detail=f"Failed to create Nextcloud folder '{built}': {resp.status_code}",
)
async def write_info_file(customer_folder: str, customer_name: str, customer_id: str) -> None:
"""Write a _info.txt stub into a new customer folder for human browsability."""
content = f"Customer: {customer_name}\nID: {customer_id}\n"
await upload_file(
f"{customer_folder}/_info.txt",
content.encode("utf-8"),
"text/plain",
)
_PROPFIND_BODY = b"""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getcontenttype/>
<D:getcontentlength/>
<D:getlastmodified/>
</D:prop>
</D:propfind>"""
async def list_folder(relative_path: str) -> List[dict]:
"""
PROPFIND at depth=1 to list a folder's immediate children.
relative_path is relative to nextcloud_base_path.
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if resp.status_code == 404:
return []
if resp.status_code not in (207, 200):
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
return _parse_propfind(resp.content, relative_path)
async def list_folder_recursive(relative_path: str) -> List[dict]:
"""
Recursively list ALL files under a folder (any depth).
Tries Depth:infinity first (single call). Falls back to manual recursion
via Depth:1 if the server returns 403/400 (some servers disable infinity).
Returns only file entries (is_dir=False).
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "infinity", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if resp.status_code in (207, 200):
all_items = _parse_propfind(resp.content, relative_path)
return [item for item in all_items if not item["is_dir"]]
# Depth:infinity not supported — fall back to recursive Depth:1
if resp.status_code in (403, 400, 412):
return await _list_recursive_fallback(relative_path)
if resp.status_code == 404:
return []
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
async def _list_recursive_fallback(relative_path: str) -> List[dict]:
"""Manually recurse via Depth:1 calls when Depth:infinity is blocked."""
items = await list_folder(relative_path)
files = []
dirs = []
for item in items:
if item["is_dir"]:
dirs.append(item["path"])
else:
files.append(item)
for dir_path in dirs:
child_files = await _list_recursive_fallback(dir_path)
files.extend(child_files)
return files
async def upload_file(relative_path: str, content: bytes, mime_type: str) -> str:
"""
PUT a file to Nextcloud. Returns the relative_path on success.
relative_path includes filename, e.g. "customers/abc123/media/photo.jpg"
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.put(
url,
auth=_auth(),
content=content,
headers={"Content-Type": mime_type},
)
if resp.status_code not in (200, 201, 204):
raise HTTPException(status_code=502, detail=f"Nextcloud upload failed: {resp.status_code}")
return relative_path
async def download_file(relative_path: str) -> tuple[bytes, str]:
"""
GET a file from Nextcloud. Returns (bytes, mime_type).
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.get(url, auth=_auth())
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Nextcloud download failed: {resp.status_code}")
mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
return resp.content, mime
async def delete_file(relative_path: str) -> None:
"""DELETE a file from Nextcloud."""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request("DELETE", url, auth=_auth())
if resp.status_code not in (200, 204, 404):
raise HTTPException(status_code=502, detail=f"Nextcloud delete failed: {resp.status_code}")

View File

@@ -0,0 +1,305 @@
"""
Nextcloud WebDAV proxy endpoints.
Folder convention (all paths relative to nextcloud_base_path = BellSystems/Console):
customers/{folder_id}/media/
customers/{folder_id}/documents/
customers/{folder_id}/sent/
customers/{folder_id}/received/
folder_id = customer.folder_id if set, else customer.id (legacy fallback).
"""
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request
from typing import Optional
from jose import JWTError
from auth.models import TokenPayload
from auth.dependencies import require_permission
from auth.utils import decode_access_token
from crm import nextcloud, service
from crm.models import MediaCreate, MediaDirection
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
DIRECTION_MAP = {
"sent": MediaDirection.sent,
"received": MediaDirection.received,
"internal": MediaDirection.internal,
"media": MediaDirection.internal,
"documents": MediaDirection.internal,
}
@router.get("/browse")
async def browse(
path: str = Query(..., description="Path relative to nextcloud_base_path"),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""List immediate children of a Nextcloud folder."""
items = await nextcloud.list_folder(path)
return {"path": path, "items": items}
@router.get("/browse-all")
async def browse_all(
customer_id: str = Query(..., description="Customer ID"),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""
Recursively list ALL files for a customer across all subfolders and any depth.
Uses Depth:infinity (one WebDAV call) with automatic fallback to recursive Depth:1.
Each file item includes a 'subfolder' key derived from its path.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
all_files = await nextcloud.list_folder_recursive(base)
# Tag each file with the top-level subfolder it lives under
for item in all_files:
parts = item["path"].split("/")
# path looks like: customers/{nc_path}/{subfolder}/[...]/filename
# parts[0]=customers, parts[1]={nc_path}, parts[2]={subfolder}
item["subfolder"] = parts[2] if len(parts) > 2 else "other"
return {"items": all_files}
@router.get("/file")
async def proxy_file(
request: Request,
path: str = Query(..., description="Path relative to nextcloud_base_path"),
token: Optional[str] = Query(None, description="JWT token for browser-native requests (img src, video src, a href) that cannot send an Authorization header"),
):
"""
Stream a file from Nextcloud through the backend (proxy).
Supports HTTP Range requests so videos can be seeked and start playing immediately.
Accepts auth via Authorization: Bearer header OR ?token= query param.
"""
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
try:
decode_access_token(token)
except (JWTError, KeyError):
raise HTTPException(status_code=403, detail="Invalid token")
content, mime_type = await nextcloud.download_file(path)
total = len(content)
range_header = request.headers.get("range")
if range_header and range_header.startswith("bytes="):
# Parse "bytes=start-end"
try:
range_spec = range_header[6:]
start_str, _, end_str = range_spec.partition("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else total - 1
end = min(end, total - 1)
chunk = content[start:end + 1]
headers = {
"Content-Range": f"bytes {start}-{end}/{total}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Content-Type": mime_type,
}
return Response(content=chunk, status_code=206, headers=headers, media_type=mime_type)
except (ValueError, IndexError):
pass
return Response(
content=content,
media_type=mime_type,
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
)
@router.put("/file-put")
async def put_file(
request: Request,
path: str = Query(..., description="Path relative to nextcloud_base_path"),
token: Optional[str] = Query(None),
):
"""
Overwrite a file in Nextcloud with a new body (used for TXT in-browser editing).
Auth via ?token= query param (same pattern as /file GET).
"""
if token is None:
raise HTTPException(status_code=403, detail="Not authenticated")
try:
decode_access_token(token)
except (JWTError, KeyError):
raise HTTPException(status_code=403, detail="Invalid token")
body = await request.body()
content_type = request.headers.get("content-type", "text/plain")
await nextcloud.upload_file(path, body, content_type)
return {"updated": path}
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
customer_id: str = Form(...),
subfolder: str = Form("media"), # "media" | "documents" | "sent" | "received"
direction: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Upload a file to the customer's Nextcloud folder and record it in crm_media.
Uses the customer's folder_id as the NC path (falls back to UUID for legacy records).
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
target_folder = f"customers/{nc_path}/{subfolder}"
file_path = f"{target_folder}/{file.filename}"
# Ensure the target subfolder exists (idempotent, fast for existing folders)
await nextcloud.ensure_folder(target_folder)
# Read and upload
content = await file.read()
mime_type = file.content_type or "application/octet-stream"
await nextcloud.upload_file(file_path, content, mime_type)
# Resolve direction
resolved_direction = None
if direction:
try:
resolved_direction = MediaDirection(direction)
except ValueError:
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
else:
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
# Save metadata record
tag_list = [t.strip() for t in tags.split(",")] if tags else []
media_record = await service.create_media(MediaCreate(
customer_id=customer_id,
filename=file.filename,
nextcloud_path=file_path,
mime_type=mime_type,
direction=resolved_direction,
tags=tag_list,
uploaded_by=_user.name,
))
return media_record
@router.delete("/file")
async def delete_file(
path: str = Query(..., description="Path relative to nextcloud_base_path"),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""Delete a file from Nextcloud and remove the matching crm_media record if found."""
await nextcloud.delete_file(path)
# Best-effort: delete the DB record if one matches this path
media_list = await service.list_media()
for m in media_list:
if m.nextcloud_path == path:
try:
await service.delete_media(m.id)
except Exception:
pass
break
return {"deleted": path}
@router.post("/init-customer-folder")
async def init_customer_folder(
customer_id: str = Form(...),
customer_name: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Create the standard folder structure for a customer in Nextcloud
and write an _info.txt stub for human readability.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
for sub in ("media", "documents", "sent", "received"):
await nextcloud.ensure_folder(f"{base}/{sub}")
await nextcloud.write_info_file(base, customer_name, customer_id)
return {"initialized": base}
@router.post("/sync")
async def sync_nextcloud_files(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Scan the customer's Nextcloud folder and register any files not yet tracked in the DB.
Returns counts of newly synced and skipped (already tracked) files.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
# Collect all NC files recursively (handles nested folders at any depth)
all_nc_files = await nextcloud.list_folder_recursive(base)
for item in all_nc_files:
parts = item["path"].split("/")
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
# Get existing DB records for this customer
existing = await service.list_media(customer_id=customer_id)
tracked_paths = {m.nextcloud_path for m in existing}
synced = 0
skipped = 0
for f in all_nc_files:
if f["path"] in tracked_paths:
skipped += 1
continue
sub = f["_subfolder"]
direction = DIRECTION_MAP.get(sub, MediaDirection.internal)
await service.create_media(MediaCreate(
customer_id=customer_id,
filename=f["filename"],
nextcloud_path=f["path"],
mime_type=f.get("mime_type") or "application/octet-stream",
direction=direction,
tags=[],
uploaded_by="nextcloud-sync",
))
synced += 1
return {"synced": synced, "skipped": skipped}
@router.post("/untrack-deleted")
async def untrack_deleted_files(
customer_id: str = Form(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Remove DB records for files that no longer exist in Nextcloud.
Returns count of untracked records.
"""
customer = service.get_customer(customer_id)
nc_path = service.get_customer_nc_path(customer)
base = f"customers/{nc_path}"
# Collect all NC file paths recursively
all_nc_files = await nextcloud.list_folder_recursive(base)
nc_paths = {item["path"] for item in all_nc_files}
# Find DB records whose NC path no longer exists
existing = await service.list_media(customer_id=customer_id)
untracked = 0
for m in existing:
if m.nextcloud_path and m.nextcloud_path not in nc_paths:
try:
await service.delete_media(m.id)
untracked += 1
except Exception:
pass
return {"untracked": untracked}

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_permission
from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse
from crm import service
router = APIRouter(prefix="/api/crm/orders", tags=["crm-orders"])
@router.get("", response_model=OrderListResponse)
def list_orders(
customer_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
payment_status: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
orders = service.list_orders(
customer_id=customer_id,
status=status,
payment_status=payment_status,
)
return OrderListResponse(orders=orders, total=len(orders))
@router.get("/{order_id}", response_model=OrderInDB)
def get_order(
order_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return service.get_order(order_id)
@router.post("", response_model=OrderInDB, status_code=201)
def create_order(
body: OrderCreate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return service.create_order(body)
@router.put("/{order_id}", response_model=OrderInDB)
def update_order(
order_id: str,
body: OrderUpdate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return service.update_order(order_id, body)
@router.delete("/{order_id}", status_code=204)
def delete_order(
order_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
service.delete_order(order_id)

View File

@@ -0,0 +1,141 @@
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class QuotationStatus(str, Enum):
draft = "draft"
sent = "sent"
accepted = "accepted"
rejected = "rejected"
class QuotationItemCreate(BaseModel):
product_id: Optional[str] = None
description: Optional[str] = None
unit_type: str = "pcs" # pcs / kg / m
unit_cost: float = 0.0
discount_percent: float = 0.0
quantity: float = 1.0
vat_percent: float = 24.0
sort_order: int = 0
class QuotationItemInDB(QuotationItemCreate):
id: str
quotation_id: str
line_total: float = 0.0
class QuotationCreate(BaseModel):
customer_id: str
title: Optional[str] = None
subtitle: Optional[str] = None
language: str = "en" # en / gr
order_type: Optional[str] = None
shipping_method: Optional[str] = None
estimated_shipping_date: Optional[str] = None
global_discount_label: Optional[str] = None
global_discount_percent: float = 0.0
shipping_cost: float = 0.0
shipping_cost_discount: float = 0.0
install_cost: float = 0.0
install_cost_discount: float = 0.0
extras_label: Optional[str] = None
extras_cost: float = 0.0
comments: List[str] = []
quick_notes: Optional[Dict[str, Any]] = None
items: List[QuotationItemCreate] = []
# Client override fields (for this quotation only; customer record is not modified)
client_org: Optional[str] = None
client_name: Optional[str] = None
client_location: Optional[str] = None
client_phone: Optional[str] = None
client_email: Optional[str] = None
class QuotationUpdate(BaseModel):
title: Optional[str] = None
subtitle: Optional[str] = None
language: Optional[str] = None
status: Optional[QuotationStatus] = None
order_type: Optional[str] = None
shipping_method: Optional[str] = None
estimated_shipping_date: Optional[str] = None
global_discount_label: Optional[str] = None
global_discount_percent: Optional[float] = None
shipping_cost: Optional[float] = None
shipping_cost_discount: Optional[float] = None
install_cost: Optional[float] = None
install_cost_discount: Optional[float] = None
extras_label: Optional[str] = None
extras_cost: Optional[float] = None
comments: Optional[List[str]] = None
quick_notes: Optional[Dict[str, Any]] = None
items: Optional[List[QuotationItemCreate]] = None
# Client override fields
client_org: Optional[str] = None
client_name: Optional[str] = None
client_location: Optional[str] = None
client_phone: Optional[str] = None
client_email: Optional[str] = None
class QuotationInDB(BaseModel):
id: str
quotation_number: str
customer_id: str
title: Optional[str] = None
subtitle: Optional[str] = None
language: str = "en"
status: QuotationStatus = QuotationStatus.draft
order_type: Optional[str] = None
shipping_method: Optional[str] = None
estimated_shipping_date: Optional[str] = None
global_discount_label: Optional[str] = None
global_discount_percent: float = 0.0
shipping_cost: float = 0.0
shipping_cost_discount: float = 0.0
install_cost: float = 0.0
install_cost_discount: float = 0.0
extras_label: Optional[str] = None
extras_cost: float = 0.0
comments: List[str] = []
quick_notes: Dict[str, Any] = {}
subtotal_before_discount: float = 0.0
global_discount_amount: float = 0.0
new_subtotal: float = 0.0
vat_amount: float = 0.0
final_total: float = 0.0
nextcloud_pdf_path: Optional[str] = None
nextcloud_pdf_url: Optional[str] = None
created_at: str
updated_at: str
items: List[QuotationItemInDB] = []
# Client override fields
client_org: Optional[str] = None
client_name: Optional[str] = None
client_location: Optional[str] = None
client_phone: Optional[str] = None
client_email: Optional[str] = None
class QuotationListItem(BaseModel):
id: str
quotation_number: str
title: Optional[str] = None
customer_id: str
status: QuotationStatus
final_total: float
created_at: str
updated_at: str
nextcloud_pdf_url: Optional[str] = None
class QuotationListResponse(BaseModel):
quotations: List[QuotationListItem]
total: int
class NextNumberResponse(BaseModel):
next_number: str

View File

@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from typing import Optional
import io
from auth.dependencies import require_permission
from auth.models import TokenPayload
from crm.quotation_models import (
NextNumberResponse,
QuotationCreate,
QuotationInDB,
QuotationListResponse,
QuotationUpdate,
)
from crm import quotations_service as svc
router = APIRouter(prefix="/api/crm/quotations", tags=["crm-quotations"])
# IMPORTANT: Static paths must come BEFORE /{id} to avoid route collision in FastAPI
@router.get("/next-number", response_model=NextNumberResponse)
async def get_next_number(
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""Returns the next available quotation number (preview only — does not commit)."""
next_num = await svc.get_next_number()
return NextNumberResponse(next_number=next_num)
@router.get("/customer/{customer_id}", response_model=QuotationListResponse)
async def list_quotations_for_customer(
customer_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
quotations = await svc.list_quotations(customer_id)
return QuotationListResponse(quotations=quotations, total=len(quotations))
@router.get("/{quotation_id}/pdf")
async def proxy_quotation_pdf(
quotation_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
"""Proxy the quotation PDF from Nextcloud to bypass browser cookie restrictions."""
pdf_bytes = await svc.get_quotation_pdf_bytes(quotation_id)
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": "inline"},
)
@router.get("/{quotation_id}", response_model=QuotationInDB)
async def get_quotation(
quotation_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return await svc.get_quotation(quotation_id)
@router.post("", response_model=QuotationInDB, status_code=201)
async def create_quotation(
body: QuotationCreate,
generate_pdf: bool = Query(False),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Create a quotation. Pass ?generate_pdf=true to immediately generate and upload the PDF.
"""
return await svc.create_quotation(body, generate_pdf=generate_pdf)
@router.put("/{quotation_id}", response_model=QuotationInDB)
async def update_quotation(
quotation_id: str,
body: QuotationUpdate,
generate_pdf: bool = Query(False),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""
Update a quotation. Pass ?generate_pdf=true to regenerate the PDF.
"""
return await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf)
@router.delete("/{quotation_id}", status_code=204)
async def delete_quotation(
quotation_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
await svc.delete_quotation(quotation_id)
@router.post("/{quotation_id}/regenerate-pdf", response_model=QuotationInDB)
async def regenerate_pdf(
quotation_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""Force PDF regeneration and re-upload to Nextcloud."""
return await svc.regenerate_pdf(quotation_id)

View File

@@ -0,0 +1,494 @@
import json
import logging
import os
import uuid
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from crm import nextcloud
from crm.quotation_models import (
QuotationCreate,
QuotationInDB,
QuotationItemCreate,
QuotationItemInDB,
QuotationListItem,
QuotationUpdate,
)
from crm.service import get_customer
from mqtt import database as mqtt_db
logger = logging.getLogger(__name__)
# Path to Jinja2 templates directory (relative to this file)
_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _d(value) -> Decimal:
"""Convert to Decimal safely."""
return Decimal(str(value if value is not None else 0))
def _float(d: Decimal) -> float:
"""Round Decimal to 2dp and return as float for storage."""
return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def _calculate_totals(
items: list,
global_discount_percent: float,
shipping_cost: float,
shipping_cost_discount: float,
install_cost: float,
install_cost_discount: float,
extras_cost: float,
) -> dict:
"""
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
VAT is computed per-item from each item's vat_percent field.
Shipping and install costs carry 0% VAT.
Returns a dict of floats ready for DB storage.
"""
# Per-line totals and per-item VAT
item_totals = []
item_vat = Decimal(0)
for item in items:
cost = _d(item.get("unit_cost", 0))
qty = _d(item.get("quantity", 1))
disc = _d(item.get("discount_percent", 0))
net = cost * qty * (1 - disc / 100)
item_totals.append(net)
vat_pct = _d(item.get("vat_percent", 24))
item_vat += net * (vat_pct / 100)
# Shipping net (VAT = 0%)
ship_gross = _d(shipping_cost)
ship_disc = _d(shipping_cost_discount)
ship_net = ship_gross * (1 - ship_disc / 100)
# Install net (VAT = 0%)
install_gross = _d(install_cost)
install_disc = _d(install_cost_discount)
install_net = install_gross * (1 - install_disc / 100)
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
global_disc_pct = _d(global_discount_percent)
global_disc_amount = subtotal * (global_disc_pct / 100)
new_subtotal = subtotal - global_disc_amount
# Global discount proportionally reduces VAT too
if subtotal > 0:
disc_ratio = new_subtotal / subtotal
vat_amount = item_vat * disc_ratio
else:
vat_amount = Decimal(0)
extras = _d(extras_cost)
final_total = new_subtotal + vat_amount + extras
return {
"subtotal_before_discount": _float(subtotal),
"global_discount_amount": _float(global_disc_amount),
"new_subtotal": _float(new_subtotal),
"vat_amount": _float(vat_amount),
"final_total": _float(final_total),
}
def _calc_line_total(item) -> float:
cost = _d(item.get("unit_cost", 0))
qty = _d(item.get("quantity", 1))
disc = _d(item.get("discount_percent", 0))
return _float(cost * qty * (1 - disc / 100))
async def _generate_quotation_number(db) -> str:
year = datetime.utcnow().year
prefix = f"QT-{year}-"
rows = await db.execute_fetchall(
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
(f"{prefix}%",),
)
if rows:
last_num = rows[0][0] # e.g. "QT-2026-012"
try:
seq = int(last_num[len(prefix):]) + 1
except ValueError:
seq = 1
else:
seq = 1
return f"{prefix}{seq:03d}"
def _row_to_quotation(row: dict, items: list[dict]) -> QuotationInDB:
row = dict(row)
row["comments"] = json.loads(row.get("comments") or "[]")
row["quick_notes"] = json.loads(row.get("quick_notes") or "{}")
item_models = [QuotationItemInDB(**{k: v for k, v in i.items() if k in QuotationItemInDB.model_fields}) for i in items]
return QuotationInDB(**{k: v for k, v in row.items() if k in QuotationInDB.model_fields}, items=item_models)
def _row_to_list_item(row: dict) -> QuotationListItem:
return QuotationListItem(**{k: v for k, v in dict(row).items() if k in QuotationListItem.model_fields})
async def _fetch_items(db, quotation_id: str) -> list[dict]:
rows = await db.execute_fetchall(
"SELECT * FROM crm_quotation_items WHERE quotation_id = ? ORDER BY sort_order ASC",
(quotation_id,),
)
return [dict(r) for r in rows]
# ── Public API ────────────────────────────────────────────────────────────────
async def get_next_number() -> str:
db = await mqtt_db.get_db()
return await _generate_quotation_number(db)
async def list_quotations(customer_id: str) -> list[QuotationListItem]:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, nextcloud_pdf_url "
"FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC",
(customer_id,),
)
return [_row_to_list_item(dict(r)) for r in rows]
async def get_quotation(quotation_id: str) -> QuotationInDB:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Quotation not found")
items = await _fetch_items(db, quotation_id)
return _row_to_quotation(dict(rows[0]), items)
async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> QuotationInDB:
db = await mqtt_db.get_db()
now = datetime.utcnow().isoformat()
qid = str(uuid.uuid4())
quotation_number = await _generate_quotation_number(db)
# Build items list for calculation
items_raw = [item.model_dump() for item in data.items]
# Calculate per-item line totals
for item in items_raw:
item["line_total"] = _calc_line_total(item)
totals = _calculate_totals(
items_raw,
data.global_discount_percent,
data.shipping_cost,
data.shipping_cost_discount,
data.install_cost,
data.install_cost_discount,
data.extras_cost,
)
comments_json = json.dumps(data.comments)
quick_notes_json = json.dumps(data.quick_notes or {})
await db.execute(
"""INSERT INTO crm_quotations (
id, quotation_number, title, subtitle, customer_id,
language, status, order_type, shipping_method, estimated_shipping_date,
global_discount_label, global_discount_percent,
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
extras_label, extras_cost, comments, quick_notes,
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
nextcloud_pdf_path, nextcloud_pdf_url,
client_org, client_name, client_location, client_phone, client_email,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?,
?, 'draft', ?, ?, ?,
?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?,
NULL, NULL,
?, ?, ?, ?, ?,
?, ?
)""",
(
qid, quotation_number, data.title, data.subtitle, data.customer_id,
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
data.global_discount_label, data.global_discount_percent,
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
totals["subtotal_before_discount"], totals["global_discount_amount"],
totals["new_subtotal"], totals["vat_amount"], totals["final_total"],
data.client_org, data.client_name, data.client_location, data.client_phone, data.client_email,
now, now,
),
)
# Insert items
for i, item in enumerate(items_raw):
item_id = str(uuid.uuid4())
await db.execute(
"""INSERT INTO crm_quotation_items
(id, quotation_id, product_id, description, unit_type, unit_cost,
discount_percent, quantity, vat_percent, line_total, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
item_id, qid, item.get("product_id"), item.get("description"),
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
item.get("discount_percent", 0), item.get("quantity", 1),
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
),
)
await db.commit()
quotation = await get_quotation(qid)
if generate_pdf:
quotation = await _do_generate_and_upload_pdf(quotation)
return quotation
async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pdf: bool = False) -> QuotationInDB:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Quotation not found")
existing = dict(rows[0])
now = datetime.utcnow().isoformat()
# Merge update into existing values
update_fields = data.model_dump(exclude_none=True)
# Build SET clause — handle comments JSON separately
set_parts = []
params = []
scalar_fields = [
"title", "subtitle", "language", "status", "order_type", "shipping_method",
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
"shipping_cost", "shipping_cost_discount", "install_cost",
"install_cost_discount", "extras_label", "extras_cost",
"client_org", "client_name", "client_location", "client_phone", "client_email",
]
for field in scalar_fields:
if field in update_fields:
set_parts.append(f"{field} = ?")
params.append(update_fields[field])
if "comments" in update_fields:
set_parts.append("comments = ?")
params.append(json.dumps(update_fields["comments"]))
if "quick_notes" in update_fields:
set_parts.append("quick_notes = ?")
params.append(json.dumps(update_fields["quick_notes"] or {}))
# Recalculate totals using merged values
merged = {**existing, **{k: update_fields.get(k, existing.get(k)) for k in scalar_fields}}
# If items are being updated, recalculate with new items; otherwise use existing items
if "items" in update_fields:
items_raw = [item.model_dump() for item in data.items]
for item in items_raw:
item["line_total"] = _calc_line_total(item)
else:
existing_items = await _fetch_items(db, quotation_id)
items_raw = existing_items
totals = _calculate_totals(
items_raw,
float(merged.get("global_discount_percent", 0)),
float(merged.get("shipping_cost", 0)),
float(merged.get("shipping_cost_discount", 0)),
float(merged.get("install_cost", 0)),
float(merged.get("install_cost_discount", 0)),
float(merged.get("extras_cost", 0)),
)
for field, val in totals.items():
set_parts.append(f"{field} = ?")
params.append(val)
set_parts.append("updated_at = ?")
params.append(now)
params.append(quotation_id)
if set_parts:
await db.execute(
f"UPDATE crm_quotations SET {', '.join(set_parts)} WHERE id = ?",
params,
)
# Replace items if provided
if "items" in update_fields:
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
for i, item in enumerate(items_raw):
item_id = str(uuid.uuid4())
await db.execute(
"""INSERT INTO crm_quotation_items
(id, quotation_id, product_id, description, unit_type, unit_cost,
discount_percent, quantity, vat_percent, line_total, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
item_id, quotation_id, item.get("product_id"), item.get("description"),
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
item.get("discount_percent", 0), item.get("quantity", 1),
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
),
)
await db.commit()
quotation = await get_quotation(quotation_id)
if generate_pdf:
quotation = await _do_generate_and_upload_pdf(quotation)
return quotation
async def delete_quotation(quotation_id: str) -> None:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT nextcloud_pdf_path FROM crm_quotations WHERE id = ?", (quotation_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Quotation not found")
pdf_path = dict(rows[0]).get("nextcloud_pdf_path")
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
await db.execute("DELETE FROM crm_quotations WHERE id = ?", (quotation_id,))
await db.commit()
# Remove PDF from Nextcloud (best-effort)
if pdf_path:
try:
await nextcloud.delete_file(pdf_path)
except Exception as e:
logger.warning("Failed to delete PDF from Nextcloud (%s): %s", pdf_path, e)
# ── PDF Generation ─────────────────────────────────────────────────────────────
async def _do_generate_and_upload_pdf(quotation: QuotationInDB) -> QuotationInDB:
"""Generate PDF, upload to Nextcloud, update DB record. Returns updated quotation."""
try:
customer = get_customer(quotation.customer_id)
except Exception as e:
logger.error("Cannot generate PDF — customer not found: %s", e)
return quotation
try:
pdf_bytes = await _generate_pdf_bytes(quotation, customer)
except Exception as e:
logger.error("PDF generation failed for quotation %s: %s", quotation.id, e)
return quotation
# Delete old PDF if present
if quotation.nextcloud_pdf_path:
try:
await nextcloud.delete_file(quotation.nextcloud_pdf_path)
except Exception:
pass
try:
pdf_path, pdf_url = await _upload_pdf(customer, quotation, pdf_bytes)
except Exception as e:
logger.error("PDF upload failed for quotation %s: %s", quotation.id, e)
return quotation
# Persist paths
db = await mqtt_db.get_db()
await db.execute(
"UPDATE crm_quotations SET nextcloud_pdf_path = ?, nextcloud_pdf_url = ? WHERE id = ?",
(pdf_path, pdf_url, quotation.id),
)
await db.commit()
return await get_quotation(quotation.id)
async def _generate_pdf_bytes(quotation: QuotationInDB, customer) -> bytes:
"""Render Jinja2 template and convert to PDF via WeasyPrint."""
from jinja2 import Environment, FileSystemLoader, select_autoescape
import weasyprint
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=select_autoescape(["html"]),
)
def format_money(value):
try:
f = float(value)
# Greek-style: dot thousands separator, comma decimal
formatted = f"{f:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{formatted}"
except (TypeError, ValueError):
return "0,00 €"
env.filters["format_money"] = format_money
template = env.get_template("quotation.html")
html_str = template.render(
quotation=quotation,
customer=customer,
lang=quotation.language,
)
pdf = weasyprint.HTML(string=html_str, base_url=str(_TEMPLATES_DIR)).write_pdf()
return pdf
async def _upload_pdf(customer, quotation: QuotationInDB, pdf_bytes: bytes) -> tuple[str, str]:
"""Upload PDF to Nextcloud, return (relative_path, public_url)."""
from crm.service import get_customer_nc_path
from config import settings
nc_folder = get_customer_nc_path(customer)
date_str = datetime.utcnow().strftime("%Y-%m-%d")
filename = f"Quotation-{quotation.quotation_number}-{date_str}.pdf"
rel_path = f"customers/{nc_folder}/quotations/{filename}"
await nextcloud.ensure_folder(f"customers/{nc_folder}/quotations")
await nextcloud.upload_file(rel_path, pdf_bytes, "application/pdf")
# Construct a direct WebDAV download URL
from crm.nextcloud import _full_url
pdf_url = _full_url(rel_path)
return rel_path, pdf_url
async def regenerate_pdf(quotation_id: str) -> QuotationInDB:
quotation = await get_quotation(quotation_id)
return await _do_generate_and_upload_pdf(quotation)
async def get_quotation_pdf_bytes(quotation_id: str) -> bytes:
"""Download the PDF for a quotation from Nextcloud and return raw bytes."""
from fastapi import HTTPException
quotation = await get_quotation(quotation_id)
if not quotation.nextcloud_pdf_path:
raise HTTPException(status_code=404, detail="No PDF generated for this quotation")
pdf_bytes, _ = await nextcloud.download_file(quotation.nextcloud_pdf_path)
return pdf_bytes

93
backend/crm/router.py Normal file
View File

@@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from typing import Optional
import os
import shutil
from auth.models import TokenPayload
from auth.dependencies import require_permission
from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse
from crm import service
router = APIRouter(prefix="/api/crm/products", tags=["crm-products"])
PHOTO_DIR = os.path.join(os.path.dirname(__file__), "..", "storage", "product_images")
os.makedirs(PHOTO_DIR, exist_ok=True)
@router.get("", response_model=ProductListResponse)
def list_products(
search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
active_only: bool = Query(False),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
products = service.list_products(search=search, category=category, active_only=active_only)
return ProductListResponse(products=products, total=len(products))
@router.get("/{product_id}", response_model=ProductInDB)
def get_product(
product_id: str,
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return service.get_product(product_id)
@router.post("", response_model=ProductInDB, status_code=201)
def create_product(
body: ProductCreate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return service.create_product(body)
@router.put("/{product_id}", response_model=ProductInDB)
def update_product(
product_id: str,
body: ProductUpdate,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return service.update_product(product_id, body)
@router.delete("/{product_id}", status_code=204)
def delete_product(
product_id: str,
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
service.delete_product(product_id)
@router.post("/{product_id}/photo", response_model=ProductInDB)
async def upload_product_photo(
product_id: str,
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
"""Upload a product photo. Accepts JPG or PNG, stored on disk."""
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
raise HTTPException(status_code=400, detail="Only JPG, PNG, or WebP images are accepted.")
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(file.content_type, "jpg")
photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}")
# Remove any old photo files for this product
for old_ext in ("jpg", "png", "webp"):
old_path = os.path.join(PHOTO_DIR, f"{product_id}.{old_ext}")
if os.path.exists(old_path) and old_path != photo_path:
os.remove(old_path)
with open(photo_path, "wb") as f:
shutil.copyfileobj(file.file, f)
photo_url = f"/crm/products/{product_id}/photo"
return service.update_product(product_id, ProductUpdate(photo_url=photo_url))
@router.get("/{product_id}/photo")
def get_product_photo(
product_id: str,
):
"""Serve a product photo from disk."""
for ext in ("jpg", "png", "webp"):
photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}")
if os.path.exists(photo_path):
return FileResponse(photo_path)
raise HTTPException(status_code=404, detail="No photo found for this product.")

619
backend/crm/service.py Normal file
View File

@@ -0,0 +1,619 @@
import json
import uuid
from datetime import datetime
from fastapi import HTTPException
from shared.firebase import get_db
from shared.exceptions import NotFoundError
import re as _re
from mqtt import database as mqtt_db
from crm.models import (
ProductCreate, ProductUpdate, ProductInDB,
CustomerCreate, CustomerUpdate, CustomerInDB,
OrderCreate, OrderUpdate, OrderInDB,
CommCreate, CommUpdate, CommInDB,
MediaCreate, MediaInDB,
)
COLLECTION = "crm_products"
def _doc_to_product(doc) -> ProductInDB:
data = doc.to_dict()
return ProductInDB(id=doc.id, **data)
def list_products(
search: str | None = None,
category: str | None = None,
active_only: bool = False,
) -> list[ProductInDB]:
db = get_db()
query = db.collection(COLLECTION)
if active_only:
query = query.where("active", "==", True)
if category:
query = query.where("category", "==", category)
results = []
for doc in query.stream():
product = _doc_to_product(doc)
if search:
s = search.lower()
if not (
s in (product.name or "").lower()
or s in (product.sku or "").lower()
or s in (product.description or "").lower()
):
continue
results.append(product)
return results
def get_product(product_id: str) -> ProductInDB:
db = get_db()
doc = db.collection(COLLECTION).document(product_id).get()
if not doc.exists:
raise NotFoundError("Product")
return _doc_to_product(doc)
def create_product(data: ProductCreate) -> ProductInDB:
db = get_db()
now = datetime.utcnow().isoformat()
product_id = str(uuid.uuid4())
doc_data = data.model_dump()
doc_data["created_at"] = now
doc_data["updated_at"] = now
# Serialize nested enums/models
if doc_data.get("category"):
doc_data["category"] = doc_data["category"].value if hasattr(doc_data["category"], "value") else doc_data["category"]
if doc_data.get("costs") and hasattr(doc_data["costs"], "model_dump"):
doc_data["costs"] = doc_data["costs"].model_dump()
if doc_data.get("stock") and hasattr(doc_data["stock"], "model_dump"):
doc_data["stock"] = doc_data["stock"].model_dump()
db.collection(COLLECTION).document(product_id).set(doc_data)
return ProductInDB(id=product_id, **doc_data)
def update_product(product_id: str, data: ProductUpdate) -> ProductInDB:
db = get_db()
doc_ref = db.collection(COLLECTION).document(product_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Product")
update_data = data.model_dump(exclude_none=True)
update_data["updated_at"] = datetime.utcnow().isoformat()
if "category" in update_data and hasattr(update_data["category"], "value"):
update_data["category"] = update_data["category"].value
if "costs" in update_data and hasattr(update_data["costs"], "model_dump"):
update_data["costs"] = update_data["costs"].model_dump()
if "stock" in update_data and hasattr(update_data["stock"], "model_dump"):
update_data["stock"] = update_data["stock"].model_dump()
doc_ref.update(update_data)
updated_doc = doc_ref.get()
return _doc_to_product(updated_doc)
def delete_product(product_id: str) -> None:
db = get_db()
doc_ref = db.collection(COLLECTION).document(product_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Product")
doc_ref.delete()
# ── Customers ────────────────────────────────────────────────────────────────
CUSTOMERS_COLLECTION = "crm_customers"
def _doc_to_customer(doc) -> CustomerInDB:
data = doc.to_dict()
return CustomerInDB(id=doc.id, **data)
def list_customers(
search: str | None = None,
tag: str | None = None,
) -> list[CustomerInDB]:
db = get_db()
query = db.collection(CUSTOMERS_COLLECTION)
if tag:
query = query.where("tags", "array_contains", tag)
results = []
for doc in query.stream():
customer = _doc_to_customer(doc)
if search:
s = search.lower()
name_match = s in (customer.name or "").lower()
surname_match = s in (customer.surname or "").lower()
org_match = s in (customer.organization or "").lower()
contact_match = any(
s in (c.value or "").lower()
for c in (customer.contacts or [])
)
loc = customer.location or {}
loc_match = (
s in (loc.get("city", "") or "").lower() or
s in (loc.get("country", "") or "").lower() or
s in (loc.get("region", "") or "").lower()
)
tag_match = any(s in (t or "").lower() for t in (customer.tags or []))
if not (name_match or surname_match or org_match or contact_match or loc_match or tag_match):
continue
results.append(customer)
return results
def get_customer(customer_id: str) -> CustomerInDB:
db = get_db()
doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get()
if not doc.exists:
raise NotFoundError("Customer")
return _doc_to_customer(doc)
def get_customer_nc_path(customer: CustomerInDB) -> str:
"""Return the Nextcloud folder slug for a customer. Falls back to UUID for legacy records."""
return customer.folder_id if customer.folder_id else customer.id
def create_customer(data: CustomerCreate) -> CustomerInDB:
db = get_db()
# Validate folder_id
if not data.folder_id or not data.folder_id.strip():
raise HTTPException(status_code=422, detail="Internal Folder ID is required.")
folder_id = data.folder_id.strip().lower()
if not _re.match(r'^[a-z0-9][a-z0-9\-]*[a-z0-9]$', folder_id):
raise HTTPException(
status_code=422,
detail="Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.",
)
# Check uniqueness
existing = list(db.collection(CUSTOMERS_COLLECTION).where("folder_id", "==", folder_id).limit(1).stream())
if existing:
raise HTTPException(status_code=409, detail=f"A customer with folder ID '{folder_id}' already exists.")
now = datetime.utcnow().isoformat()
customer_id = str(uuid.uuid4())
doc_data = data.model_dump()
doc_data["folder_id"] = folder_id
doc_data["created_at"] = now
doc_data["updated_at"] = now
db.collection(CUSTOMERS_COLLECTION).document(customer_id).set(doc_data)
return CustomerInDB(id=customer_id, **doc_data)
def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
db = get_db()
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Customer")
update_data = data.model_dump(exclude_none=True)
update_data["updated_at"] = datetime.utcnow().isoformat()
doc_ref.update(update_data)
updated_doc = doc_ref.get()
return _doc_to_customer(updated_doc)
def delete_customer(customer_id: str) -> None:
db = get_db()
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Customer")
doc_ref.delete()
# ── Orders ───────────────────────────────────────────────────────────────────
ORDERS_COLLECTION = "crm_orders"
def _doc_to_order(doc) -> OrderInDB:
data = doc.to_dict()
return OrderInDB(id=doc.id, **data)
def _generate_order_number(db) -> str:
year = datetime.utcnow().year
prefix = f"ORD-{year}-"
max_n = 0
for doc in db.collection(ORDERS_COLLECTION).stream():
data = doc.to_dict()
num = data.get("order_number", "")
if num and num.startswith(prefix):
try:
n = int(num[len(prefix):])
if n > max_n:
max_n = n
except ValueError:
pass
return f"{prefix}{max_n + 1:03d}"
def list_orders(
customer_id: str | None = None,
status: str | None = None,
payment_status: str | None = None,
) -> list[OrderInDB]:
db = get_db()
query = db.collection(ORDERS_COLLECTION)
if customer_id:
query = query.where("customer_id", "==", customer_id)
if status:
query = query.where("status", "==", status)
if payment_status:
query = query.where("payment_status", "==", payment_status)
return [_doc_to_order(doc) for doc in query.stream()]
def get_order(order_id: str) -> OrderInDB:
db = get_db()
doc = db.collection(ORDERS_COLLECTION).document(order_id).get()
if not doc.exists:
raise NotFoundError("Order")
return _doc_to_order(doc)
def create_order(data: OrderCreate) -> OrderInDB:
db = get_db()
now = datetime.utcnow().isoformat()
order_id = str(uuid.uuid4())
doc_data = data.model_dump()
if not doc_data.get("order_number"):
doc_data["order_number"] = _generate_order_number(db)
doc_data["created_at"] = now
doc_data["updated_at"] = now
db.collection(ORDERS_COLLECTION).document(order_id).set(doc_data)
return OrderInDB(id=order_id, **doc_data)
def update_order(order_id: str, data: OrderUpdate) -> OrderInDB:
db = get_db()
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Order")
update_data = data.model_dump(exclude_none=True)
update_data["updated_at"] = datetime.utcnow().isoformat()
doc_ref.update(update_data)
updated_doc = doc_ref.get()
return _doc_to_order(updated_doc)
def delete_order(order_id: str) -> None:
db = get_db()
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Order")
doc_ref.delete()
# ── Comms Log (SQLite, async) ─────────────────────────────────────────────────
def _row_to_comm(row: dict) -> CommInDB:
row = dict(row)
raw_attachments = json.loads(row.get("attachments") or "[]")
# Normalise attachment dicts — tolerate both synced (content_type/size) and
# sent (nextcloud_path) shapes so Pydantic never sees missing required fields.
row["attachments"] = [
{k: v for k, v in a.items() if k in ("filename", "nextcloud_path", "content_type", "size")}
for a in raw_attachments if isinstance(a, dict) and a.get("filename")
]
if row.get("to_addrs") and isinstance(row["to_addrs"], str):
try:
row["to_addrs"] = json.loads(row["to_addrs"])
except Exception:
row["to_addrs"] = []
# SQLite stores booleans as integers
row["is_important"] = bool(row.get("is_important", 0))
row["is_read"] = bool(row.get("is_read", 0))
return CommInDB(**{k: v for k, v in row.items() if k in CommInDB.model_fields})
async def list_comms(
customer_id: str,
type: str | None = None,
direction: str | None = None,
limit: int = 100,
) -> list[CommInDB]:
db = await mqtt_db.get_db()
where = ["customer_id = ?"]
params: list = [customer_id]
if type:
where.append("type = ?")
params.append(type)
if direction:
where.append("direction = ?")
params.append(direction)
clause = " AND ".join(where)
rows = await db.execute_fetchall(
f"SELECT * FROM crm_comms_log WHERE {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
params + [limit],
)
entries = [_row_to_comm(dict(r)) for r in rows]
# Fallback: include unlinked email rows (customer_id NULL) if addresses match this customer.
# This covers historical rows created before automatic outbound customer linking.
fs = get_db()
doc = fs.collection("crm_customers").document(customer_id).get()
if doc.exists:
data = doc.to_dict() or {}
customer_emails = {
(c.get("value") or "").strip().lower()
for c in (data.get("contacts") or [])
if c.get("type") == "email" and c.get("value")
}
else:
customer_emails = set()
if customer_emails:
extra_where = [
"type = 'email'",
"(customer_id IS NULL OR customer_id = '')",
]
extra_params: list = []
if direction:
extra_where.append("direction = ?")
extra_params.append(direction)
extra_clause = " AND ".join(extra_where)
extra_rows = await db.execute_fetchall(
f"SELECT * FROM crm_comms_log WHERE {extra_clause} "
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
extra_params + [max(limit, 300)],
)
for r in extra_rows:
e = _row_to_comm(dict(r))
from_addr = (e.from_addr or "").strip().lower()
to_addrs = [(a or "").strip().lower() for a in (e.to_addrs or [])]
matched = (from_addr in customer_emails) or any(a in customer_emails for a in to_addrs)
if matched:
entries.append(e)
# De-duplicate and sort consistently
uniq = {e.id: e for e in entries}
sorted_entries = sorted(
uniq.values(),
key=lambda e: ((e.occurred_at or e.created_at or ""), (e.created_at or ""), (e.id or "")),
reverse=True,
)
return sorted_entries[:limit]
async def list_all_emails(
direction: str | None = None,
customers_only: bool = False,
mail_accounts: list[str] | None = None,
limit: int = 500,
) -> list[CommInDB]:
db = await mqtt_db.get_db()
where = ["type = 'email'"]
params: list = []
if direction:
where.append("direction = ?")
params.append(direction)
if customers_only:
where.append("customer_id IS NOT NULL")
if mail_accounts:
placeholders = ",".join("?" for _ in mail_accounts)
where.append(f"mail_account IN ({placeholders})")
params.extend(mail_accounts)
clause = f"WHERE {' AND '.join(where)}"
rows = await db.execute_fetchall(
f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
params + [limit],
)
return [_row_to_comm(dict(r)) for r in rows]
async def list_all_comms(
type: str | None = None,
direction: str | None = None,
limit: int = 200,
) -> list[CommInDB]:
db = await mqtt_db.get_db()
where = []
params: list = []
if type:
where.append("type = ?")
params.append(type)
if direction:
where.append("direction = ?")
params.append(direction)
clause = f"WHERE {' AND '.join(where)}" if where else ""
rows = await db.execute_fetchall(
f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?",
params + [limit],
)
return [_row_to_comm(dict(r)) for r in rows]
async def get_comm(comm_id: str) -> CommInDB:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT * FROM crm_comms_log WHERE id = ?", (comm_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Comm entry not found")
return _row_to_comm(dict(rows[0]))
async def create_comm(data: CommCreate) -> CommInDB:
db = await mqtt_db.get_db()
now = datetime.utcnow().isoformat()
comm_id = str(uuid.uuid4())
occurred_at = data.occurred_at or now
attachments_json = json.dumps([a.model_dump() for a in data.attachments])
await db.execute(
"""INSERT INTO crm_comms_log
(id, customer_id, type, mail_account, direction, subject, body, attachments,
ext_message_id, logged_by, occurred_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(comm_id, data.customer_id, data.type.value, data.mail_account, data.direction.value,
data.subject, data.body, attachments_json,
data.ext_message_id, data.logged_by, occurred_at, now),
)
await db.commit()
return await get_comm(comm_id)
async def update_comm(comm_id: str, data: CommUpdate) -> CommInDB:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Comm entry not found")
updates = data.model_dump(exclude_none=True)
if not updates:
return await get_comm(comm_id)
set_clause = ", ".join(f"{k} = ?" for k in updates)
await db.execute(
f"UPDATE crm_comms_log SET {set_clause} WHERE id = ?",
list(updates.values()) + [comm_id],
)
await db.commit()
return await get_comm(comm_id)
async def delete_comm(comm_id: str) -> None:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Comm entry not found")
await db.execute("DELETE FROM crm_comms_log WHERE id = ?", (comm_id,))
await db.commit()
async def delete_comms_bulk(ids: list[str]) -> int:
"""Delete multiple comm entries. Returns count deleted."""
if not ids:
return 0
db = await mqtt_db.get_db()
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"DELETE FROM crm_comms_log WHERE id IN ({placeholders})", ids
)
await db.commit()
return cursor.rowcount
async def set_comm_important(comm_id: str, important: bool) -> CommInDB:
db = await mqtt_db.get_db()
await db.execute(
"UPDATE crm_comms_log SET is_important = ? WHERE id = ?",
(1 if important else 0, comm_id),
)
await db.commit()
return await get_comm(comm_id)
async def set_comm_read(comm_id: str, read: bool) -> CommInDB:
db = await mqtt_db.get_db()
await db.execute(
"UPDATE crm_comms_log SET is_read = ? WHERE id = ?",
(1 if read else 0, comm_id),
)
await db.commit()
return await get_comm(comm_id)
# ── Media (SQLite, async) ─────────────────────────────────────────────────────
def _row_to_media(row: dict) -> MediaInDB:
row = dict(row)
row["tags"] = json.loads(row.get("tags") or "[]")
return MediaInDB(**row)
async def list_media(
customer_id: str | None = None,
order_id: str | None = None,
) -> list[MediaInDB]:
db = await mqtt_db.get_db()
where = []
params: list = []
if customer_id:
where.append("customer_id = ?")
params.append(customer_id)
if order_id:
where.append("order_id = ?")
params.append(order_id)
clause = f"WHERE {' AND '.join(where)}" if where else ""
rows = await db.execute_fetchall(
f"SELECT * FROM crm_media {clause} ORDER BY created_at DESC",
params,
)
return [_row_to_media(dict(r)) for r in rows]
async def create_media(data: MediaCreate) -> MediaInDB:
db = await mqtt_db.get_db()
now = datetime.utcnow().isoformat()
media_id = str(uuid.uuid4())
tags_json = json.dumps(data.tags)
direction = data.direction.value if data.direction else None
await db.execute(
"""INSERT INTO crm_media
(id, customer_id, order_id, filename, nextcloud_path, mime_type,
direction, tags, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(media_id, data.customer_id, data.order_id, data.filename,
data.nextcloud_path, data.mime_type, direction,
tags_json, data.uploaded_by, now),
)
await db.commit()
rows = await db.execute_fetchall(
"SELECT * FROM crm_media WHERE id = ?", (media_id,)
)
return _row_to_media(dict(rows[0]))
async def delete_media(media_id: str) -> None:
db = await mqtt_db.get_db()
rows = await db.execute_fetchall(
"SELECT id FROM crm_media WHERE id = ?", (media_id,)
)
if not rows:
raise HTTPException(status_code=404, detail="Media entry not found")
await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,))
await db.commit()

View File

@@ -7,6 +7,8 @@ from devices.models import (
DeviceUsersResponse, DeviceUserInfo, DeviceUsersResponse, DeviceUserInfo,
) )
from devices import service from devices import service
from mqtt import database as mqtt_db
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
router = APIRouter(prefix="/api/devices", tags=["devices"]) router = APIRouter(prefix="/api/devices", tags=["devices"])
@@ -67,3 +69,13 @@ async def delete_device(
_user: TokenPayload = Depends(require_permission("devices", "delete")), _user: TokenPayload = Depends(require_permission("devices", "delete")),
): ):
service.delete_device(device_id) service.delete_device(device_id)
@router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse)
async def get_device_alerts(
device_id: str,
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
"""Return the current active alert set for a device. Empty list means fully healthy."""
rows = await mqtt_db.get_alerts(device_id)
return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows])

View File

@@ -1,15 +1,24 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from enum import Enum
class UpdateType(str, Enum):
optional = "optional" # user-initiated only
mandatory = "mandatory" # auto-installs on next reboot
emergency = "emergency" # auto-installs on reboot + daily check + MQTT push
class FirmwareVersion(BaseModel): class FirmwareVersion(BaseModel):
id: str id: str
hw_type: str # "vs", "vp", "vx" hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro"
channel: str # "stable", "beta", "alpha", "testing" channel: str # "stable", "beta", "alpha", "testing"
version: str # semver e.g. "1.4.2" version: str # semver e.g. "1.5"
filename: str filename: str
size_bytes: int size_bytes: int
sha256: str sha256: str
update_type: UpdateType = UpdateType.mandatory
min_fw_version: Optional[str] = None # minimum fw version required to install this
uploaded_at: str uploaded_at: str
notes: Optional[str] = None notes: Optional[str] = None
is_latest: bool = False is_latest: bool = False
@@ -20,12 +29,19 @@ class FirmwareListResponse(BaseModel):
total: int total: int
class FirmwareLatestResponse(BaseModel): class FirmwareMetadataResponse(BaseModel):
"""Returned by both /latest and /{version}/info endpoints."""
hw_type: str hw_type: str
channel: str channel: str
version: str version: str
size_bytes: int size_bytes: int
sha256: str sha256: str
update_type: UpdateType
min_fw_version: Optional[str] = None
download_url: str download_url: str
uploaded_at: str uploaded_at: str
notes: Optional[str] = None notes: Optional[str] = None
# Keep backwards-compatible alias
FirmwareLatestResponse = FirmwareMetadataResponse

View File

@@ -4,7 +4,7 @@ from typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_permission from auth.dependencies import require_permission
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
from firmware import service from firmware import service
router = APIRouter(prefix="/api/firmware", tags=["firmware"]) router = APIRouter(prefix="/api/firmware", tags=["firmware"])
@@ -15,6 +15,8 @@ async def upload_firmware(
hw_type: str = Form(...), hw_type: str = Form(...),
channel: str = Form(...), channel: str = Form(...),
version: str = Form(...), version: str = Form(...),
update_type: UpdateType = Form(UpdateType.mandatory),
min_fw_version: Optional[str] = Form(None),
notes: Optional[str] = Form(None), notes: Optional[str] = Form(None),
file: UploadFile = File(...), file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")), _user: TokenPayload = Depends(require_permission("manufacturing", "add")),
@@ -25,6 +27,8 @@ async def upload_firmware(
channel=channel, channel=channel,
version=version, version=version,
file_bytes=file_bytes, file_bytes=file_bytes,
update_type=update_type,
min_fw_version=min_fw_version,
notes=notes, notes=notes,
) )
@@ -39,7 +43,7 @@ def list_firmware(
return FirmwareListResponse(firmware=items, total=len(items)) return FirmwareListResponse(firmware=items, total=len(items))
@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse) @router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse)
def get_latest_firmware(hw_type: str, channel: str): def get_latest_firmware(hw_type: str, channel: str):
"""Returns metadata for the latest firmware for a given hw_type + channel. """Returns metadata for the latest firmware for a given hw_type + channel.
No auth required — devices call this endpoint to check for updates. No auth required — devices call this endpoint to check for updates.
@@ -47,6 +51,14 @@ def get_latest_firmware(hw_type: str, channel: str):
return service.get_latest(hw_type, channel) return service.get_latest(hw_type, channel)
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
def get_firmware_info(hw_type: str, channel: str, version: str):
"""Returns metadata for a specific firmware version.
No auth required — devices call this to resolve upgrade chains.
"""
return service.get_version_info(hw_type, channel, version)
@router.get("/{hw_type}/{channel}/{version}/firmware.bin") @router.get("/{hw_type}/{channel}/{version}/firmware.bin")
def download_firmware(hw_type: str, channel: str, version: str): def download_firmware(hw_type: str, channel: str, version: str):
"""Download the firmware binary. No auth required — devices call this directly.""" """Download the firmware binary. No auth required — devices call this directly."""

View File

@@ -8,11 +8,11 @@ from fastapi import HTTPException
from config import settings from config import settings
from shared.firebase import get_db from shared.firebase import get_db
from shared.exceptions import NotFoundError from shared.exceptions import NotFoundError
from firmware.models import FirmwareVersion, FirmwareLatestResponse from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
COLLECTION = "firmware_versions" COLLECTION = "firmware_versions"
VALID_HW_TYPES = {"vs", "vp", "vx"} VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"} VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
@@ -36,23 +36,43 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
filename=data.get("filename", "firmware.bin"), filename=data.get("filename", "firmware.bin"),
size_bytes=data.get("size_bytes", 0), size_bytes=data.get("size_bytes", 0),
sha256=data.get("sha256", ""), sha256=data.get("sha256", ""),
update_type=data.get("update_type", UpdateType.mandatory),
min_fw_version=data.get("min_fw_version"),
uploaded_at=uploaded_str, uploaded_at=uploaded_str,
notes=data.get("notes"), notes=data.get("notes"),
is_latest=data.get("is_latest", False), is_latest=data.get("is_latest", False),
) )
def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin"
return FirmwareMetadataResponse(
hw_type=fw.hw_type,
channel=fw.channel,
version=fw.version,
size_bytes=fw.size_bytes,
sha256=fw.sha256,
update_type=fw.update_type,
min_fw_version=fw.min_fw_version,
download_url=download_url,
uploaded_at=fw.uploaded_at,
notes=fw.notes,
)
def upload_firmware( def upload_firmware(
hw_type: str, hw_type: str,
channel: str, channel: str,
version: str, version: str,
file_bytes: bytes, file_bytes: bytes,
update_type: UpdateType = UpdateType.mandatory,
min_fw_version: str | None = None,
notes: str | None = None, notes: str | None = None,
) -> FirmwareVersion: ) -> FirmwareVersion:
if hw_type not in VALID_HW_TYPES: if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}") raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}")
if channel not in VALID_CHANNELS: if channel not in VALID_CHANNELS:
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}") raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
dest = _storage_path(hw_type, channel, version) dest = _storage_path(hw_type, channel, version)
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
@@ -83,6 +103,8 @@ def upload_firmware(
"filename": "firmware.bin", "filename": "firmware.bin",
"size_bytes": len(file_bytes), "size_bytes": len(file_bytes),
"sha256": sha256, "sha256": sha256,
"update_type": update_type.value,
"min_fw_version": min_fw_version,
"uploaded_at": now, "uploaded_at": now,
"notes": notes, "notes": notes,
"is_latest": True, "is_latest": True,
@@ -108,7 +130,7 @@ def list_firmware(
return items return items
def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse: def get_latest(hw_type: str, channel: str) -> FirmwareMetadataResponse:
if hw_type not in VALID_HW_TYPES: if hw_type not in VALID_HW_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'") raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
if channel not in VALID_CHANNELS: if channel not in VALID_CHANNELS:
@@ -126,18 +148,29 @@ def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
if not docs: if not docs:
raise NotFoundError("Firmware") raise NotFoundError("Firmware")
fw = _doc_to_firmware_version(docs[0]) return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin"
return FirmwareLatestResponse(
hw_type=fw.hw_type, def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetadataResponse:
channel=fw.channel, """Fetch metadata for a specific version. Used by devices resolving upgrade chains."""
version=fw.version, if hw_type not in VALID_HW_TYPES:
size_bytes=fw.size_bytes, raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
sha256=fw.sha256, if channel not in VALID_CHANNELS:
download_url=download_url, raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
uploaded_at=fw.uploaded_at,
notes=fw.notes, db = get_db()
docs = list(
db.collection(COLLECTION)
.where("hw_type", "==", hw_type)
.where("channel", "==", channel)
.where("version", "==", version)
.limit(1)
.stream()
) )
if not docs:
raise NotFoundError("Firmware version")
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path: def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:

View File

@@ -17,6 +17,15 @@ from builder.router import router as builder_router
from manufacturing.router import router as manufacturing_router from manufacturing.router import router as manufacturing_router
from firmware.router import router as firmware_router from firmware.router import router as firmware_router
from admin.router import router as admin_router from admin.router import router as admin_router
from crm.router import router as crm_products_router
from crm.customers_router import router as crm_customers_router
from crm.orders_router import router as crm_orders_router
from crm.comms_router import router as crm_comms_router
from crm.media_router import router as crm_media_router
from crm.nextcloud_router import router as crm_nextcloud_router
from crm.quotations_router import router as crm_quotations_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 from mqtt.client import mqtt_manager
from mqtt import database as mqtt_db from mqtt import database as mqtt_db
from melodies import service as melody_service from melodies import service as melody_service
@@ -50,6 +59,30 @@ app.include_router(builder_router)
app.include_router(manufacturing_router) app.include_router(manufacturing_router)
app.include_router(firmware_router) app.include_router(firmware_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(crm_products_router)
app.include_router(crm_customers_router)
app.include_router(crm_orders_router)
app.include_router(crm_comms_router)
app.include_router(crm_media_router)
app.include_router(crm_nextcloud_router)
app.include_router(crm_quotations_router)
async def nextcloud_keepalive_loop():
await nextcloud_keepalive() # eager warmup on startup
while True:
await asyncio.sleep(45)
await nextcloud_keepalive()
async def email_sync_loop():
while True:
await asyncio.sleep(settings.email_sync_interval_minutes * 60)
try:
from crm.email_sync import sync_emails
await sync_emails()
except Exception as e:
print(f"[EMAIL SYNC] Error: {e}")
@app.on_event("startup") @app.on_event("startup")
@@ -59,12 +92,20 @@ async def startup():
await melody_service.migrate_from_firestore() await melody_service.migrate_from_firestore()
mqtt_manager.start(asyncio.get_event_loop()) mqtt_manager.start(asyncio.get_event_loop())
asyncio.create_task(mqtt_db.purge_loop()) asyncio.create_task(mqtt_db.purge_loop())
asyncio.create_task(nextcloud_keepalive_loop())
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
if sync_accounts:
print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop")
asyncio.create_task(email_sync_loop())
else:
print("[EMAIL SYNC] IMAP not configured - sync loop disabled")
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown(): async def shutdown():
mqtt_manager.stop() mqtt_manager.stop()
await mqtt_db.close_db() await mqtt_db.close_db()
await close_nextcloud_client()
@app.get("/api/health") @app.get("/api/health")
@@ -74,3 +115,4 @@ async def health_check():
"firebase": firebase_initialized, "firebase": firebase_initialized,
"mqtt": mqtt_manager.connected, "mqtt": mqtt_manager.connected,
} }

View File

@@ -4,23 +4,23 @@ from enum import Enum
class BoardType(str, Enum): class BoardType(str, Enum):
vs = "vs" # Vesper vesper = "vesper"
vp = "vp" # Vesper Plus vesper_plus = "vesper_plus"
vx = "vx" # Vesper Pro vesper_pro = "vesper_pro"
cb = "cb" # Chronos chronos = "chronos"
cp = "cp" # Chronos Pro chronos_pro = "chronos_pro"
am = "am" # Agnus Mini agnus_mini = "agnus_mini"
ab = "ab" # Agnus agnus = "agnus"
BOARD_TYPE_LABELS = { BOARD_TYPE_LABELS = {
"vs": "Vesper", "vesper": "Vesper",
"vp": "Vesper Plus", "vesper_plus": "Vesper+",
"vx": "Vesper Pro", "vesper_pro": "Vesper Pro",
"cb": "Chronos", "chronos": "Chronos",
"cp": "Chronos Pro", "chronos_pro": "Chronos Pro",
"am": "Agnus Mini", "agnus_mini": "Agnus Mini",
"ab": "Agnus", "agnus": "Agnus",
} }

View File

@@ -26,7 +26,7 @@ class MqttManager:
self._client = paho_mqtt.Client( self._client = paho_mqtt.Client(
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2, callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
client_id="bellsystems-admin-panel", client_id=settings.mqtt_client_id,
clean_session=True, clean_session=True,
) )
@@ -64,6 +64,8 @@ class MqttManager:
client.subscribe([ client.subscribe([
("vesper/+/data", 1), ("vesper/+/data", 1),
("vesper/+/status/heartbeat", 1), ("vesper/+/status/heartbeat", 1),
("vesper/+/status/alerts", 1),
("vesper/+/status/info", 0),
("vesper/+/logs", 1), ("vesper/+/logs", 1),
]) ])
else: else:

View File

@@ -76,6 +76,102 @@ SCHEMA_STATEMENTS = [
)""", )""",
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)", "CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)", "CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)",
# Active device alerts (current state, not history)
"""CREATE TABLE IF NOT EXISTS device_alerts (
device_serial TEXT NOT NULL,
subsystem TEXT NOT NULL,
state TEXT NOT NULL,
message TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (device_serial, subsystem)
)""",
"CREATE INDEX IF NOT EXISTS idx_device_alerts_serial ON device_alerts(device_serial)",
# CRM communications log
"""CREATE TABLE IF NOT EXISTS crm_comms_log (
id TEXT PRIMARY KEY,
customer_id TEXT,
type TEXT NOT NULL,
mail_account TEXT,
direction TEXT NOT NULL,
subject TEXT,
body TEXT,
body_html TEXT,
attachments TEXT NOT NULL DEFAULT '[]',
ext_message_id TEXT,
from_addr TEXT,
to_addrs TEXT,
logged_by TEXT,
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL
)""",
"CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)",
# CRM media references
"""CREATE TABLE IF NOT EXISTS crm_media (
id TEXT PRIMARY KEY,
customer_id TEXT,
order_id TEXT,
filename TEXT NOT NULL,
nextcloud_path TEXT NOT NULL,
mime_type TEXT,
direction TEXT,
tags TEXT NOT NULL DEFAULT '[]',
uploaded_by TEXT,
created_at TEXT NOT NULL
)""",
"CREATE INDEX IF NOT EXISTS idx_crm_media_customer ON crm_media(customer_id)",
"CREATE INDEX IF NOT EXISTS idx_crm_media_order ON crm_media(order_id)",
# CRM sync state (last email sync timestamp, etc.)
"""CREATE TABLE IF NOT EXISTS crm_sync_state (
key TEXT PRIMARY KEY,
value TEXT
)""",
# CRM Quotations
"""CREATE TABLE IF NOT EXISTS crm_quotations (
id TEXT PRIMARY KEY,
quotation_number TEXT UNIQUE NOT NULL,
title TEXT,
subtitle TEXT,
customer_id TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'en',
status TEXT NOT NULL DEFAULT 'draft',
order_type TEXT,
shipping_method TEXT,
estimated_shipping_date TEXT,
global_discount_label TEXT,
global_discount_percent REAL NOT NULL DEFAULT 0,
vat_percent REAL NOT NULL DEFAULT 24,
shipping_cost REAL NOT NULL DEFAULT 0,
shipping_cost_discount REAL NOT NULL DEFAULT 0,
install_cost REAL NOT NULL DEFAULT 0,
install_cost_discount REAL NOT NULL DEFAULT 0,
extras_label TEXT,
extras_cost REAL NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '[]',
subtotal_before_discount REAL NOT NULL DEFAULT 0,
global_discount_amount REAL NOT NULL DEFAULT 0,
new_subtotal REAL NOT NULL DEFAULT 0,
vat_amount REAL NOT NULL DEFAULT 0,
final_total REAL NOT NULL DEFAULT 0,
nextcloud_pdf_path TEXT,
nextcloud_pdf_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""",
"""CREATE TABLE IF NOT EXISTS crm_quotation_items (
id TEXT PRIMARY KEY,
quotation_id TEXT NOT NULL,
product_id TEXT,
description TEXT,
unit_type TEXT NOT NULL DEFAULT 'pcs',
unit_cost REAL NOT NULL DEFAULT 0,
discount_percent REAL NOT NULL DEFAULT 0,
quantity REAL NOT NULL DEFAULT 1,
line_total REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (quotation_id) REFERENCES crm_quotations(id)
)""",
"CREATE INDEX IF NOT EXISTS idx_crm_quotations_customer ON crm_quotations(customer_id)",
"CREATE INDEX IF NOT EXISTS idx_crm_quotation_items_quotation ON crm_quotation_items(quotation_id, sort_order)",
] ]
@@ -86,6 +182,65 @@ async def init_db():
for stmt in SCHEMA_STATEMENTS: for stmt in SCHEMA_STATEMENTS:
await _db.execute(stmt) await _db.execute(stmt)
await _db.commit() await _db.commit()
# Migrations: add columns that may not exist in older DBs
_migrations = [
"ALTER TABLE crm_comms_log ADD COLUMN body_html TEXT",
"ALTER TABLE crm_comms_log ADD COLUMN mail_account TEXT",
"ALTER TABLE crm_comms_log ADD COLUMN from_addr TEXT",
"ALTER TABLE crm_comms_log ADD COLUMN to_addrs TEXT",
"ALTER TABLE crm_comms_log ADD COLUMN is_important INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE crm_comms_log ADD COLUMN is_read INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE crm_quotation_items ADD COLUMN vat_percent REAL NOT NULL DEFAULT 24",
"ALTER TABLE crm_quotations ADD COLUMN quick_notes TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE crm_quotations ADD COLUMN client_org TEXT",
"ALTER TABLE crm_quotations ADD COLUMN client_name TEXT",
"ALTER TABLE crm_quotations ADD COLUMN client_location TEXT",
"ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT",
"ALTER TABLE crm_quotations ADD COLUMN client_email TEXT",
]
for m in _migrations:
try:
await _db.execute(m)
await _db.commit()
except Exception:
pass # column already exists
# Migration: drop NOT NULL on crm_comms_log.customer_id if it exists.
# SQLite doesn't support ALTER COLUMN, so we check via table_info and
# rebuild the table if needed.
rows = await _db.execute_fetchall("PRAGMA table_info(crm_comms_log)")
for row in rows:
# row: (cid, name, type, notnull, dflt_value, pk)
if row[1] == "customer_id" and row[3] == 1: # notnull=1
logger.info("Migrating crm_comms_log: removing NOT NULL from customer_id")
await _db.execute("ALTER TABLE crm_comms_log RENAME TO crm_comms_log_old")
await _db.execute("""CREATE TABLE crm_comms_log (
id TEXT PRIMARY KEY,
customer_id TEXT,
type TEXT NOT NULL,
mail_account TEXT,
direction TEXT NOT NULL,
subject TEXT,
body TEXT,
body_html TEXT,
attachments TEXT NOT NULL DEFAULT '[]',
ext_message_id TEXT,
from_addr TEXT,
to_addrs TEXT,
logged_by TEXT,
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL
)""")
await _db.execute("""INSERT INTO crm_comms_log
SELECT id, customer_id, type, NULL, direction, subject, body, body_html,
attachments, ext_message_id, from_addr, to_addrs, logged_by,
occurred_at, created_at
FROM crm_comms_log_old""")
await _db.execute("DROP TABLE crm_comms_log_old")
await _db.execute("CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)")
await _db.commit()
logger.info("Migration complete: crm_comms_log.customer_id is now nullable")
break
logger.info(f"SQLite database initialized at {settings.sqlite_db_path}") logger.info(f"SQLite database initialized at {settings.sqlite_db_path}")
@@ -252,3 +407,37 @@ async def purge_loop():
await purge_old_data() await purge_old_data()
except Exception as e: except Exception as e:
logger.error(f"Purge failed: {e}") logger.error(f"Purge failed: {e}")
# --- Device Alerts ---
async def upsert_alert(device_serial: str, subsystem: str, state: str,
message: str | None = None):
db = await get_db()
await db.execute(
"""INSERT INTO device_alerts (device_serial, subsystem, state, message, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(device_serial, subsystem)
DO UPDATE SET state=excluded.state, message=excluded.message,
updated_at=excluded.updated_at""",
(device_serial, subsystem, state, message),
)
await db.commit()
async def delete_alert(device_serial: str, subsystem: str):
db = await get_db()
await db.execute(
"DELETE FROM device_alerts WHERE device_serial = ? AND subsystem = ?",
(device_serial, subsystem),
)
await db.commit()
async def get_alerts(device_serial: str) -> list:
db = await get_db()
rows = await db.execute_fetchall(
"SELECT * FROM device_alerts WHERE device_serial = ? ORDER BY updated_at DESC",
(device_serial,),
)
return [dict(r) for r in rows]

View File

@@ -18,6 +18,10 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
try: try:
if topic_type == "status/heartbeat": if topic_type == "status/heartbeat":
await _handle_heartbeat(serial, payload) await _handle_heartbeat(serial, payload)
elif topic_type == "status/alerts":
await _handle_alerts(serial, payload)
elif topic_type == "status/info":
await _handle_info(serial, payload)
elif topic_type == "logs": elif topic_type == "logs":
await _handle_log(serial, payload) await _handle_log(serial, payload)
elif topic_type == "data": elif topic_type == "data":
@@ -29,6 +33,8 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
async def _handle_heartbeat(serial: str, payload: dict): async def _handle_heartbeat(serial: str, payload: dict):
# Store silently — do not log as a visible event.
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
inner = payload.get("payload", {}) inner = payload.get("payload", {})
await db.insert_heartbeat( await db.insert_heartbeat(
device_serial=serial, device_serial=serial,
@@ -55,6 +61,31 @@ async def _handle_log(serial: str, payload: dict):
) )
async def _handle_alerts(serial: str, payload: dict):
subsystem = payload.get("subsystem", "")
state = payload.get("state", "")
if not subsystem or not state:
logger.warning(f"Malformed alert payload from {serial}: {payload}")
return
if state == "CLEARED":
await db.delete_alert(serial, subsystem)
else:
await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
async def _handle_info(serial: str, payload: dict):
event_type = payload.get("type", "")
data = payload.get("payload", {})
if event_type == "playback_started":
logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}")
elif event_type == "playback_stopped":
logger.debug(f"{serial}: playback stopped")
else:
logger.debug(f"{serial}: info event '{event_type}'")
async def _handle_data_response(serial: str, payload: dict): async def _handle_data_response(serial: str, payload: dict):
status = payload.get("status", "") status = payload.get("status", "")

View File

@@ -84,3 +84,15 @@ class CommandSendResponse(BaseModel):
success: bool success: bool
command_id: int command_id: int
message: str message: str
class DeviceAlertEntry(BaseModel):
device_serial: str
subsystem: str
state: str
message: Optional[str] = None
updated_at: str
class DeviceAlertsResponse(BaseModel):
alerts: List[DeviceAlertEntry]

View File

@@ -10,3 +10,6 @@ python-multipart==0.0.20
bcrypt==4.0.1 bcrypt==4.0.1
aiosqlite==0.20.0 aiosqlite==0.20.0
resend==2.10.0 resend==2.10.0
httpx>=0.27.0
weasyprint>=62.0
jinja2>=3.1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
backend/templates/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,708 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8"/>
<title>{% if lang == 'gr' %}Προσφορά{% else %}Quotation{% endif %} {{ quotation.quotation_number }}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans', DejaVu Sans, Arial, sans-serif;
font-size: 9.5pt;
color: #1a1a2e;
background: #fff;
line-height: 1.45;
display: flex;
flex-direction: column;
min-height: 100vh;
padding-bottom: 36mm;
}
/* pushes notes + validity down toward the fixed footer */
.main-content-gap {
flex-grow: 1;
}
@page {
size: A4;
margin: 15mm 15mm 15mm 15mm;
}
/* ── HEADER ── */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 10px;
border-bottom: 2.5px solid #5886c4;
margin-bottom: 14px;
}
.company-block {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.company-block img.logo {
max-height: 70px;
max-width: 250px;
object-fit: contain;
display: block;
margin-bottom: 5px;
}
.company-block p {
font-size: 10pt;
color: #6b8fc4;
margin-top: 1px;
}
.quotation-meta-block {
text-align: right;
}
.quotation-meta-block .doc-type {
font-size: 14pt;
font-weight: 700;
color: #5886c4;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.quotation-meta-block .meta-line {
font-size: 8.5pt;
text-align: right;
white-space: nowrap;
line-height: 1.6;
}
.quotation-meta-block .meta-line .meta-label {
color: #7a9cc8;
}
.quotation-meta-block .meta-line .meta-value {
font-weight: 600;
color: #1a1a2e;
}
/* ── CLIENT + ORDER META ── */
.info-row {
display: flex;
align-items: stretch;
gap: 16px;
margin-bottom: 12px;
}
.client-block, .order-block {
border: 1px solid #c2d4ec;
border-radius: 5px;
padding: 6px 10px;
}
.client-block { flex: 65; }
.order-block { flex: 35; }
.block-title {
font-size: 7.5pt;
font-weight: 700;
text-transform: uppercase;
color: #5886c4;
letter-spacing: 0.5px;
margin-bottom: 3px;
border-bottom: 1px solid #dce9f7;
padding-bottom: 2px;
}
.info-row table.fields {
border-collapse: collapse;
width: 100%;
}
.info-row table.fields td {
padding: 1px 0;
vertical-align: top;
}
.info-row table.fields td.lbl {
font-size: 8pt;
color: #7a9cc8;
white-space: nowrap;
padding-right: 8px;
}
.info-row table.fields td.val {
font-size: 8.5pt;
font-weight: 500;
color: #1a1a2e;
}
/* ── TITLE / SUBTITLE ── */
.quotation-title {
margin-bottom: 10px;
}
.quotation-title h2 {
font-size: 13pt;
font-weight: 700;
color: #3a6aad;
}
.quotation-title p {
font-size: 9pt;
color: #555;
margin-top: 2px;
}
/* ── ITEMS TABLE ── */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0;
font-size: 8.5pt;
}
.items-table thead tr {
background: #5886c4;
color: #fff;
}
.items-table thead th {
padding: 6px 8px;
text-align: left;
font-weight: 600;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.items-table thead th.right { text-align: right; }
.items-table thead th.center { text-align: center; }
.items-table tbody tr:nth-child(even) { background: #eef4fc; }
.items-table tbody tr:nth-child(odd) { background: #fff; }
.items-table tbody td {
padding: 5px 8px;
border-bottom: 1px solid #dce9f7;
vertical-align: middle;
}
.items-table tbody td.right { text-align: right; }
.items-table tbody td.center { text-align: center; }
.items-table tbody td.muted { color: #7a9cc8; font-size: 8pt; }
/* Special rows for shipping/install */
.items-table tbody tr.special-row td {
background: #edf3fb;
border-top: 1px solid #c2d4ec;
border-bottom: 1px solid #c2d4ec;
font-style: italic;
color: #3a6aad;
}
.items-table tbody tr.special-spacer td {
height: 6px;
background: #f4f8fd;
border: none;
padding: 0;
}
/* ── BELOW TABLE ROW: VAT notice + totals ── */
.below-table {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: 0;
margin-bottom: 14px;
}
.vat-notice {
flex: 1;
padding-top: 8px;
padding-right: 16px;
}
.vat-notice p {
font-size: 8pt;
font-weight: 700;
color: #3a6aad;
text-transform: uppercase;
letter-spacing: 0.3px;
border-left: 3px solid #5886c4;
padding-left: 7px;
padding-top: 2px;
padding-bottom: 2px;
}
/* ── TOTALS ── */
.totals-table {
width: 280px;
border-collapse: collapse;
font-size: 8.5pt;
flex-shrink: 0;
}
.totals-table td {
padding: 4px 10px;
border-bottom: 1px solid #dce9f7;
}
.totals-table .label { color: #555; text-align: right; }
.totals-table .value { text-align: right; font-weight: 500; min-width: 90px; }
.totals-table .discount-row { color: #c0392b; }
.totals-table .new-subtotal-row td { font-size: 10pt; font-weight: 700; color: #1a1a2e; }
.totals-table .vat-row td { color: #7a9cc8; font-style: italic; }
.totals-table .final-row td {
font-size: 11pt;
font-weight: 700;
color: #3a6aad;
border-top: 2px solid #5886c4;
border-bottom: none;
padding-top: 6px;
padding-bottom: 6px;
}
/* ── COMMENTS ── */
.comments-section {
margin-bottom: 14px;
}
.comments-section .section-title {
font-size: 8pt;
font-weight: 700;
text-transform: uppercase;
color: #5886c4;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.comments-section ul {
padding-left: 14px;
}
.comments-section li {
font-size: 8.5pt;
color: #333;
margin-bottom: 3px;
line-height: 1.4;
}
/* ── FOOTER (validity line only) ── */
.footer {
border-top: 1px solid #c2d4ec;
padding-top: 7px;
margin-top: 10px;
margin-bottom: 10px;
}
.footer .validity {
font-size: 7.5pt;
font-style: italic;
color: #7a9cc8;
}
/* ── FIXED BOTTOM FOOTER (repeats on every page) ── */
.fixed-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 0 0 0;
border-top: 1.5px solid #5886c4;
display: flex;
align-items: stretch;
gap: 20px;
background: #fff;
}
.footer-block {
width: 30%;
flex-shrink: 0;
}
.footer-block-title {
font-size: 7pt;
font-weight: 700;
text-transform: uppercase;
color: #5886c4;
letter-spacing: 0.4px;
margin-bottom: 4px;
border-bottom: 1px solid #dce9f7;
padding-bottom: 2px;
}
.footer-block dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px 6px;
padding-left: 0;
margin-left: 0;
}
.footer-block dt {
font-size: 7pt;
color: #7a9cc8;
white-space: nowrap;
}
.footer-block dd {
font-size: 7pt;
color: #1a1a2e;
font-weight: 500;
}
.footer-ref {
margin-left: auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
flex-shrink: 0;
}
.footer-ref .ref-quot {
font-size: 7.5pt;
font-weight: 700;
color: #5886c4;
line-height: 1.4;
}
.footer-ref .ref-page {
font-size: 7pt;
color: #7a9cc8;
line-height: 1.4;
}
.footer-ref .ref-page::after {
content: counter(page) " / " counter(pages);
}
/* ── UTILS ── */
.text-muted { color: #aaa; }
.dash { color: #ccc; }
</style>
</head>
<body>
{# ── Bilingual labels ── #}
{% if lang == 'gr' %}
{% set L_QUOTATION = "ΠΡΟΣΦΟΡΑ" %}
{% set L_NUMBER = "Αριθμός" %}
{% set L_DATE = "Ημερομηνία" %}
{% set L_CLIENT = "ΣΤΟΙΧΕΙΑ ΠΕΛΑΤΗ" %}
{% set L_ORDER_META = "ΣΤΟΙΧΕΙΑ ΠΑΡΑΓΓΕΛΙΑΣ" %}
{% set L_ORDER_TYPE = "Τύπος" %}
{% set L_SHIP_METHOD = "Τρ. Αποστολής" %}
{% set L_SHIP_DATE = "Εκτιμώμενη Παράδοση" %}
{% set L_DESC = "Περιγραφή" %}
{% set L_UNIT_COST = "Τιμή Μον." %}
{% set L_DISC = "Έκπτ." %}
{% set L_QTY = "Ποσ." %}
{% set L_UNIT = "Μον." %}
{% set L_VAT_COL = "Φ.Π.Α." %}
{% set L_TOTAL = "Σύνολο" %}
{% set L_SUBTOTAL = "Υποσύνολο" %}
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %}
{% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %}
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α." %}
{% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %}
{% set L_INSTALL_COST = "Εγκατάσταση / Installation" %}
{% set L_EXTRAS = quotation.extras_label or "Άλλα" %}
{% set L_FINAL = "ΣΥΝΟΛΟ ΠΛΗΡΩΤΕΟ" %}
{% set L_COMMENTS = "ΣΗΜΕΙΩΣΕΙΣ" %}
{% set L_VALIDITY = "Η προσφορά ισχύει για 30 ημέρες από την ημερομηνία έκδοσής της." %}
{% set L_ORG = "Φορέας" %}
{% set L_CONTACT = "Επικοινωνία" %}
{% set L_ADDRESS = "Διεύθυνση" %}
{% set L_PHONE = "Τηλέφωνο" %}
{% set L_COMPANY_ADDR = "Ε.Ο. Αντιρρίου Ιωαννίνων 23, Αγρίνιο, 30131" %}
{% set L_CONTACT_INFO = "ΣΤΟΙΧΕΙΑ ΕΠΙΚΟΙΝΩΝΙΑΣ" %}
{% set L_PAYMENT_INFO = "ΣΤΟΙΧΕΙΑ ΠΛΗΡΩΜΗΣ" %}
{% else %}
{% set L_QUOTATION = "QUOTATION" %}
{% set L_NUMBER = "Number" %}
{% set L_DATE = "Date" %}
{% set L_CLIENT = "CLIENT DETAILS" %}
{% set L_ORDER_META = "ORDER DETAILS" %}
{% set L_ORDER_TYPE = "Order Type" %}
{% set L_SHIP_METHOD = "Ship. Method" %}
{% set L_SHIP_DATE = "Est. Delivery" %}
{% set L_DESC = "Description" %}
{% set L_UNIT_COST = "Unit Cost" %}
{% set L_DISC = "Disc." %}
{% set L_QTY = "Qty" %}
{% set L_UNIT = "Unit" %}
{% set L_VAT_COL = "VAT" %}
{% set L_TOTAL = "Total" %}
{% set L_SUBTOTAL = "Subtotal" %}
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %}
{% set L_NEW_SUBTOTAL = "New Subtotal" %}
{% set L_VAT = "Total VAT" %}
{% set L_SHIPPING_COST = "Shipping / Transport" %}
{% set L_INSTALL_COST = "Installation" %}
{% set L_EXTRAS = quotation.extras_label or "Extras" %}
{% set L_FINAL = "TOTAL DUE" %}
{% set L_COMMENTS = "NOTES" %}
{% set L_VALIDITY = "This quotation is valid for 30 days from the date of issue." %}
{% set L_ORG = "Organization" %}
{% set L_CONTACT = "Contact" %}
{% set L_ADDRESS = "Location" %}
{% set L_PHONE = "Phone" %}
{% set L_COMPANY_ADDR = "E.O. Antirriou Ioanninon 23, Agrinio, 30131, Greece" %}
{% set L_CONTACT_INFO = "CONTACT INFORMATION" %}
{% set L_PAYMENT_INFO = "PAYMENT DETAILS" %}
{% endif %}
{# ── Derived values ── #}
{% set today = quotation.created_at[:10] %}
{# ── Find phone/email contacts + check if primary contact is already phone/email ── #}
{% set ns = namespace(customer_phone='', customer_email='', primary_is_phone=false, primary_is_email=false) %}
{% for contact in customer.contacts %}
{% if contact.type == 'phone' and contact.value %}{% if contact.primary %}{% set ns.customer_phone = contact.value %}{% set ns.primary_is_phone = true %}{% elif not ns.customer_phone %}{% set ns.customer_phone = contact.value %}{% endif %}{% endif %}
{% if contact.type == 'email' and contact.value %}{% if contact.primary %}{% set ns.customer_email = contact.value %}{% set ns.primary_is_email = true %}{% elif not ns.customer_email %}{% set ns.customer_email = contact.value %}{% endif %}{% endif %}
{% endfor %}
{% set customer_phone = ns.customer_phone %}
{% set customer_email = ns.customer_email %}
{% set primary_is_phone = ns.primary_is_phone %}
{% set primary_is_email = ns.primary_is_email %}
<!-- HEADER -->
<div class="header">
<div class="company-block">
<img class="logo" src="./logo.png" alt="BellSystems"/>
<p>{{ L_COMPANY_ADDR }}</p>
</div>
<div class="quotation-meta-block">
<div class="doc-type">{{ L_QUOTATION }}</div>
<div class="meta-line"><span class="meta-label">{{ L_NUMBER }}: </span><span class="meta-value">{{ quotation.quotation_number }}</span></div>
<div class="meta-line"><span class="meta-label">{{ L_DATE }}: </span><span class="meta-value">{{ today }}</span></div>
</div>
</div>
<!-- TITLE / SUBTITLE -->
{% if quotation.title %}
<div class="quotation-title">
<h2>{{ quotation.title }}</h2>
{% if quotation.subtitle %}<p>{{ quotation.subtitle }}</p>{% endif %}
</div>
{% endif %}
<!-- CLIENT + ORDER META -->
<div class="info-row">
<div class="client-block">
<div class="block-title">{{ L_CLIENT }}</div>
<table class="fields"><tbody>{% if customer.organization %}<tr><td class="lbl">{{ L_ORG }}</td><td class="val">{{ customer.organization }}</td></tr>{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}<tr><td class="lbl">{{ L_CONTACT }}</td><td class="val">{{ name_parts | join(' ') }}</td></tr>{% endif %}{% if customer.location %}{% set loc_parts = [customer.location.city, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}<tr><td class="lbl">{{ L_ADDRESS }}</td><td class="val">{{ loc_parts | join(', ') }}</td></tr>{% endif %}{% endif %}{% if customer_email %}<tr><td class="lbl">Email</td><td class="val">{{ customer_email }}</td></tr>{% endif %}{% if customer_phone %}<tr><td class="lbl">{{ L_PHONE }}</td><td class="val">{{ customer_phone }}</td></tr>{% endif %}</tbody></table>
</div>
<div class="order-block">
<div class="block-title">{{ L_ORDER_META }}</div>
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ quotation.estimated_shipping_date }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted"></td></tr>{% endif %}</tbody></table>
</div>
</div>
<!-- ITEMS TABLE -->
<table class="items-table">
<thead>
<tr>
<th style="width:38%">{{ L_DESC }}</th>
<th class="right" style="width:11%">{{ L_UNIT_COST }}</th>
<th class="center" style="width:7%">{{ L_DISC }}</th>
<th class="center" style="width:7%">{{ L_QTY }}</th>
<th class="center" style="width:7%">{{ L_UNIT }}</th>
<th class="center" style="width:6%">{{ L_VAT_COL }}</th>
<th class="right" style="width:12%">{{ L_TOTAL }}</th>
</tr>
</thead>
<tbody>
{% for item in quotation.items %}
<tr>
<td>{{ item.description or '' }}</td>
<td class="right">{{ item.unit_cost | format_money }}</td>
<td class="center">
{% if item.discount_percent and item.discount_percent > 0 %}
{{ item.discount_percent | int }}%
{% else %}
<span class="dash"></span>
{% endif %}
</td>
<td class="center">{{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}</td>
<td class="center muted">{{ item.unit_type }}</td>
<td class="center">
{% if item.vat_percent and item.vat_percent > 0 %}
{{ item.vat_percent | int }}%
{% else %}
<span class="dash"></span>
{% endif %}
</td>
<td class="right">{{ item.line_total | format_money }}</td>
</tr>
{% endfor %}
{% if quotation.items | length == 0 %}
<tr>
<td colspan="7" class="text-muted" style="text-align:center; padding: 12px;"></td>
</tr>
{% endif %}
{# ── Shipping / Install as special rows ── #}
{% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %}
{% if has_special %}
<tr class="special-spacer"><td colspan="7"></td></tr>
{% endif %}
{% if quotation.shipping_cost and quotation.shipping_cost > 0 %}
{% set ship_net = quotation.shipping_cost * (1 - quotation.shipping_cost_discount / 100) %}
<tr class="special-row">
<td>{{ L_SHIPPING_COST }}{% if quotation.shipping_cost_discount and quotation.shipping_cost_discount > 0 %} <span style="font-size:7.5pt; color:#7a9cc8;">(-{{ quotation.shipping_cost_discount | int }}%)</span>{% endif %}</td>
<td class="right">{{ quotation.shipping_cost | format_money }}</td>
<td class="center"><span class="dash"></span></td>
<td class="center">1</td>
<td class="center muted"></td>
<td class="center"><span class="dash"></span></td>
<td class="right">{{ ship_net | format_money }}</td>
</tr>
{% endif %}
{% if quotation.install_cost and quotation.install_cost > 0 %}
{% set install_net = quotation.install_cost * (1 - quotation.install_cost_discount / 100) %}
<tr class="special-row">
<td>{{ L_INSTALL_COST }}{% if quotation.install_cost_discount and quotation.install_cost_discount > 0 %} <span style="font-size:7.5pt; color:#7a9cc8;">(-{{ quotation.install_cost_discount | int }}%)</span>{% endif %}</td>
<td class="right">{{ quotation.install_cost | format_money }}</td>
<td class="center"><span class="dash"></span></td>
<td class="center">1</td>
<td class="center muted"></td>
<td class="center"><span class="dash"></span></td>
<td class="right">{{ install_net | format_money }}</td>
</tr>
{% endif %}
</tbody>
</table>
<!-- TOTALS + VAT NOTICE -->
<div class="below-table">
<div class="vat-notice">
</div>
<table class="totals-table">
<tr>
<td class="label">{{ L_SUBTOTAL }}</td>
<td class="value">{{ quotation.subtotal_before_discount | format_money }}</td>
</tr>
{% if quotation.global_discount_percent and quotation.global_discount_percent > 0 %}
<tr class="discount-row">
<td class="label">{{ L_GLOBAL_DISC }} ({{ quotation.global_discount_percent | int }}%)</td>
<td class="value">- {{ quotation.global_discount_amount | format_money }}</td>
</tr>
<tr class="new-subtotal-row">
<td class="label">{{ L_NEW_SUBTOTAL }}</td>
<td class="value">{{ quotation.new_subtotal | format_money }}</td>
</tr>
{% endif %}
<tr class="vat-row">
<td class="label">{{ L_VAT }}</td>
<td class="value">{{ quotation.vat_amount | format_money }}</td>
</tr>
{% if quotation.extras_cost and quotation.extras_cost > 0 %}
<tr>
<td class="label">{{ L_EXTRAS }}</td>
<td class="value">{{ quotation.extras_cost | format_money }}</td>
</tr>
{% endif %}
<tr class="final-row">
<td class="label">{{ L_FINAL }}</td>
<td class="value">{{ quotation.final_total | format_money }}</td>
</tr>
</table>
</div>
<!-- SPACER: flexible gap between totals and notes -->
<div class="main-content-gap"></div>
<!-- COMMENTS / NOTES -->
{% set qn = quotation.quick_notes or {} %}
{% set has_quick = (qn.payment_advance and qn.payment_advance.enabled) or (qn.lead_time and qn.lead_time.enabled) or (qn.backup_relays and qn.backup_relays.enabled) %}
{% set has_comments = quotation.comments and quotation.comments | length > 0 %}
{% if has_quick or has_comments %}
<div class="comments-section">
<div class="section-title">{{ L_COMMENTS }}</div>
<ul>
{# ── Quick Notes ── #}
{# Payment Advance #}
{% if qn.payment_advance and qn.payment_advance.enabled %}
{% set pct = qn.payment_advance.percent | string %}
{% if lang == 'gr' %}
<li>Απαιτείται προκαταβολή <strong>{{ pct }}%</strong> με την επιβεβαίωση της παραγγελίας.</li>
{% else %}
<li><strong>{{ pct }}%</strong> advance payment is required upon order confirmation.</li>
{% endif %}
{% endif %}
{# Lead Time #}
{% if qn.lead_time and qn.lead_time.enabled %}
{% set days = qn.lead_time.days | string %}
{% if lang == 'gr' %}
<li>Εκτιμώμενος χρόνος παράδοσης, <strong>{{ days }} εργάσιμες ημέρες</strong> από την επιβεβαίωση της παραγγελίας και παραλαβή της προκαταβολής.</li>
{% else %}
<li>Estimated delivery time is <strong>{{ days }} working days</strong> from order confirmation and receipt of advance payment.</li>
{% endif %}
{% endif %}
{# Backup Relays #}
{% if qn.backup_relays and qn.backup_relays.enabled %}
{% set n = qn.backup_relays.count | int %}
{% if lang == 'gr' %}
{% if n == 1 %}
<li>Συμπεριλαμβάνονται: <strong>{{ n }} έξτρα Εφεδρικό Ρελέ Ισχύος</strong></li>
{% else %}
<li>Συμπεριλαμβάνονται: <strong>{{ n }} έξτρα Εφεδρικά Ρελέ Ισχύος</strong></li>
{% endif %}
{% else %}
{% if n == 1 %}
<li><strong>{{ n }} Extra Relay</strong> included as Backup, free of charge.</li>
{% else %}
<li><strong>{{ n }} Extra Relays</strong> included as Backups, free of charge.</li>
{% endif %}
{% endif %}
{% endif %}
{# ── Dynamic comments ── #}
{% if has_comments %}
{% for comment in quotation.comments %}
{% if comment and comment.strip() %}
<li>{{ comment }}</li>
{% endif %}
{% endfor %}
{% endif %}
</ul>
</div>
{% endif %}
<!-- VALIDITY -->
<div class="footer">
<span class="validity">{{ L_VALIDITY }}</span>
</div>
<!-- FIXED BOTTOM FOOTER: contact + payment (repeats every page) -->
<div class="fixed-footer">
<div class="footer-block">
<div class="footer-block-title">{{ L_CONTACT_INFO }}</div>
<dl>
<dt>{% if lang == 'gr' %}Εταιρεία{% else %}Company{% endif %}</dt>
<dd>BellSystems</dd>
<dt>{% if lang == 'gr' %}Τηλ.{% else %}Phone{% endif %}</dt>
<dd>+(30) 26410 33344</dd>
<dt>{% if lang == 'gr' %}Email{% else %}Email{% endif %}</dt>
<dd>sales@bellsystems.gr</dd>
<dt>Web</dt>
<dd>www.bellsystems.gr</dd>
<dt>Links</dt>
<dd><img src="./linktree.png" alt="linktr.ee/bellsystems" style="height: 7pt; vertical-align: middle;"/></dd>
</dl>
</div>
<div class="footer-block">
<div class="footer-block-title">{{ L_PAYMENT_INFO }}</div>
<dl>
<dt>{% if lang == 'gr' %}Τράπεζα{% else %}Bank{% endif %}</dt>
<dd>Piraeus Bank</dd>
<dt>{% if lang == 'gr' %}Δικαιούχος{% else %}Holder{% endif %}</dt>
<dd>Pontikas Georgios</dd>
<dt>{% if lang == 'gr' %}Αριθμός{% else %}Account No.{% endif %}</dt>
<dd>6264-1484-35226</dd>
<dt>IBAN</dt>
<dd>GR8101712640006264148435226</dd>
<dt>BIC/SWIFT</dt>
<dd>PIRBGRAA</dd>
</dl>
</div>
<div class="footer-ref">
<span class="ref-quot">{{ quotation.quotation_number }}</span>
<span class="ref-page">{% if lang == 'gr' %}Σελίδα {% else %}Page {% endif %}</span>
</div>
</div>
</body>
</html>

View File

@@ -181,7 +181,7 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
"""Generate a 0x5000-byte NVS partition binary for a Vesper device. """Generate a 0x5000-byte NVS partition binary for a Vesper device.
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA' serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
hw_type: lowercase board type e.g. 'vs', 'vp', 'vx' hw_type: board type e.g. 'vesper', 'vesper_plus', 'vesper_pro'
hw_version: zero-padded version e.g. '01' hw_version: zero-padded version e.g. '01'
Returns raw bytes ready to flash at 0x9000. Returns raw bytes ready to flash at 0x9000.

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BellSystems Admin</title> <title>BellSystems Admin</title>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -30,6 +30,13 @@ import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
import ProvisioningWizard from "./manufacturing/ProvisioningWizard"; import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
import FirmwareManager from "./firmware/FirmwareManager"; import FirmwareManager from "./firmware/FirmwareManager";
import DashboardPage from "./dashboard/DashboardPage"; import DashboardPage from "./dashboard/DashboardPage";
import ApiReferencePage from "./developer/ApiReferencePage";
import { ProductList, ProductForm } from "./crm/products";
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
import { OrderList, OrderForm, OrderDetail } from "./crm/orders";
import { QuotationForm } from "./crm/quotations";
import CommsPage from "./crm/inbox/CommsPage";
import MailPage from "./crm/mail/MailPage";
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -150,6 +157,30 @@ export default function App() {
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} /> <Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} /> <Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
{/* Mail */}
<Route path="mail" element={<PermissionGate section="crm"><MailPage /></PermissionGate>} />
{/* CRM */}
<Route path="crm/comms" element={<PermissionGate section="crm"><CommsPage /></PermissionGate>} />
<Route path="crm/inbox" element={<Navigate to="/crm/comms" replace />} />
<Route path="crm/products" element={<PermissionGate section="crm"><ProductList /></PermissionGate>} />
<Route path="crm/products/new" element={<PermissionGate section="crm" action="edit"><ProductForm /></PermissionGate>} />
<Route path="crm/products/:id" element={<PermissionGate section="crm"><ProductForm /></PermissionGate>} />
<Route path="crm/customers" element={<PermissionGate section="crm"><CustomerList /></PermissionGate>} />
<Route path="crm/customers/new" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
<Route path="crm/orders" element={<PermissionGate section="crm"><OrderList /></PermissionGate>} />
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
{/* Developer */}
{/* TODO: replace RoleGate with a dedicated "developer" permission once granular permissions are implemented */}
<Route path="developer/api" element={<RoleGate roles={["sysadmin", "admin"]}><ApiReferencePage /></RoleGate>} />
{/* Settings - Staff Management */} {/* Settings - Staff Management */}
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} /> <Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} /> <Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M500.177,55.798c0,0-21.735-7.434-39.551-11.967C411.686,31.369,308.824,24.727,256,24.727
S100.314,31.369,51.374,43.831c-17.816,4.534-39.551,11.967-39.551,11.967c-7.542,2.28-12.444,9.524-11.76,17.374l8.507,97.835
c0.757,8.596,7.957,15.201,16.581,15.201h84.787c8.506,0,15.643-6.416,16.553-14.878l4.28-39.973
c0.847-7.93,7.2-14.138,15.148-14.815c0,0,68.484-6.182,110.081-6.182c41.586,0,110.08,6.182,110.08,6.182
c7.949,0.676,14.302,6.885,15.148,14.815l4.29,39.973c0.9,8.462,8.038,14.878,16.545,14.878h84.777
c8.632,0,15.832-6.605,16.589-15.201l8.507-97.835C512.621,65.322,507.72,58.078,500.177,55.798z"/>
<path class="st0" d="M357.503,136.629h-55.365v46.137h-92.275v-46.137h-55.365c0,0-9.228,119.957-119.957,207.618
c0,32.296,0,129.95,0,129.95c0,7.218,5.857,13.076,13.075,13.076h416.768c7.218,0,13.076-5.858,13.076-13.076
c0,0,0-97.654,0-129.95C366.73,256.586,357.503,136.629,357.503,136.629z M338.768,391.42v37.406h-37.396V391.42H338.768z
M338.768,332.27v37.406h-37.396V332.27H338.768z M301.372,310.518v-37.396h37.396v37.396H301.372z M274.698,391.42v37.406h-37.396
V391.42H274.698z M274.698,332.27v37.406h-37.396V332.27H274.698z M274.698,273.122v37.396h-37.396v-37.396H274.698z
M210.629,391.42v37.406h-37.397V391.42H210.629z M210.629,332.27v37.406h-37.397V332.27H210.629z M210.629,273.122v37.396h-37.397
v-37.396H210.629z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 6.3500002 6.3500002" id="svg1976" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs1970"/>
<g id="layer1" style="display:inline">

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16421 9.66421L15.4142 3.41421L12.5858 0.585785L6.33579 6.83578L3.5 4L2 5.5V14H10.5L12 12.5L9.16421 9.66421Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M458.159,404.216c-18.93-33.65-49.934-71.764-100.409-93.431c-28.868,20.196-63.938,32.087-101.745,32.087
c-37.828,0-72.898-11.89-101.767-32.087c-50.474,21.667-81.479,59.782-100.398,93.431C28.731,448.848,48.417,512,91.842,512
c43.426,0,164.164,0,164.164,0s120.726,0,164.153,0C463.583,512,483.269,448.848,458.159,404.216z"/>
<path class="st0" d="M256.005,300.641c74.144,0,134.231-60.108,134.231-134.242v-32.158C390.236,60.108,330.149,0,256.005,0
c-74.155,0-134.252,60.108-134.252,134.242V166.4C121.753,240.533,181.851,300.641,256.005,300.641z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 18V9C6 7.34315 7.34315 6 9 6H39C40.6569 6 42 7.34315 42 9V18" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 24V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 15V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 19V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 30V39C6 40.6569 7.34315 42 9 42H39C40.6569 42 42 40.6569 42 39V30" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="phone-out" class="icon glyph"><path d="M21,15v3.93a2,2,0,0,1-2.29,2A18,18,0,0,1,3.14,5.29,2,2,0,0,1,5.13,3H9a1,1,0,0,1,1,.89,10.74,10.74,0,0,0,1,3.78,1,1,0,0,1-.42,1.26l-.86.49a1,1,0,0,0-.33,1.46,14.08,14.08,0,0,0,3.69,3.69,1,1,0,0,0,1.46-.33l.49-.86A1,1,0,0,1,16.33,13a10.74,10.74,0,0,0,3.78,1A1,1,0,0,1,21,15Z" style="fill:#231f20"></path><path d="M21,10a1,1,0,0,1-1-1,5,5,0,0,0-5-5,1,1,0,0,1,0-2,7,7,0,0,1,7,7A1,1,0,0,1,21,10Z" style="fill:#231f20"></path><path d="M17,10a1,1,0,0,1-1-1,1,1,0,0,0-1-1,1,1,0,0,1,0-2,3,3,0,0,1,3,3A1,1,0,0,1,17,10Z" style="fill:#231f20"></path></svg>

After

Width:  |  Height:  |  Size: 795 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="create-note" class="icon glyph"><path d="M20.71,3.29a2.91,2.91,0,0,0-2.2-.84,3.25,3.25,0,0,0-2.17,1L9.46,10.29s0,0,0,0a.62.62,0,0,0-.11.17,1,1,0,0,0-.1.18l0,0L8,14.72A1,1,0,0,0,9,16a.9.9,0,0,0,.28,0l4-1.17,0,0,.18-.1a.62.62,0,0,0,.17-.11l0,0,6.87-6.88a3.25,3.25,0,0,0,1-2.17A2.91,2.91,0,0,0,20.71,3.29Z"></path><path d="M20,22H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a1,1,0,0,1,0,2H4V20H20V12a1,1,0,0,1,2,0v8A2,2,0,0,1,20,22Z" style="fill:#231f20"></path></svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H5.50003L4.00003 3.5L6.83581 6.33579L0.585815 12.5858L3.41424 15.4142L9.66424 9.16421L12.5 12L14 10.5L14 2Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7 1.3 3 4.1 4.8 7.3 4.8 66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128.2 304H116c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H156c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-19 38.6-42.4 38.6zm191.8-8c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8v-68.2l-24.8 55.8c-2.9 5.9-11.4 5.9-14.3 0L224 227.8V296c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8V192c0-8.8 7.2-16 16-16h16c6.1 0 11.6 3.4 14.3 8.8l17.7 35.4 17.7-35.4c2.7-5.4 8.3-8.8 14.3-8.8h16c8.8 0 16 7.2 16 16v104zm48.3 8H356c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H396c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-18.9 38.6-42.3 38.6z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32" xml:space="preserve">
<path d="M17,0C8.7,0,2,6.7,2,15c0,3.4,1.1,6.6,3.2,9.2l-2.1,6.4c-0.1,0.4,0,0.8,0.3,1.1C3.5,31.9,3.8,32,4,32c0.1,0,0.3,0,0.4-0.1
l6.9-3.1C13.1,29.6,15,30,17,30c8.3,0,15-6.7,15-15S25.3,0,17,0z M25.7,20.5c-0.4,1.2-1.9,2.2-3.2,2.4C22.2,23,21.9,23,21.5,23
c-0.8,0-2-0.2-4.1-1.1c-2.4-1-4.8-3.1-6.7-5.8L10.7,16C10.1,15.1,9,13.4,9,11.6c0-2.2,1.1-3.3,1.5-3.8c0.5-0.5,1.2-0.8,2-0.8
c0.2,0,0.3,0,0.5,0c0.7,0,1.2,0.2,1.7,1.2l0.4,0.8c0.3,0.8,0.7,1.7,0.8,1.8c0.3,0.6,0.3,1.1,0,1.6c-0.1,0.3-0.3,0.5-0.5,0.7
c-0.1,0.2-0.2,0.3-0.3,0.3c-0.1,0.1-0.1,0.1-0.2,0.2c0.3,0.5,0.9,1.4,1.7,2.1c1.2,1.1,2.1,1.4,2.6,1.6l0,0c0.2-0.2,0.4-0.6,0.7-0.9
l0.1-0.2c0.5-0.7,1.3-0.9,2.1-0.6c0.4,0.2,2.6,1.2,2.6,1.2l0.2,0.1c0.3,0.2,0.7,0.3,0.9,0.7C26.2,18.5,25.9,19.8,25.7,20.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -71,6 +71,25 @@ export function AuthProvider({ children }) {
return roles.includes(user.role); return roles.includes(user.role);
}; };
/**
* hasPermission(section, action)
*
* Sections and their action keys:
* melodies: view, add, delete, safe_edit, full_edit, archetype_access, settings_access, compose_access
* devices: view, add, delete, safe_edit, edit_bells, edit_clock, edit_warranty, full_edit, control
* app_users: view, add, delete, safe_edit, full_edit
* issues_notes: view, add, delete, edit
* mail: view, compose, reply
* crm: activity_log
* crm_customers: full_access, overview, orders_view, orders_edit, quotations_view, quotations_edit,
* comms_view, comms_log, comms_edit, comms_compose, add, delete,
* files_view, files_edit, devices_view, devices_edit
* crm_orders: view (→ crm_customers.orders_view), edit (→ crm_customers.orders_edit) [derived]
* crm_products: view, add, edit
* mfg: view_inventory, edit, provision, firmware_view, firmware_edit
* api_reference: access
* mqtt: access
*/
const hasPermission = (section, action) => { const hasPermission = (section, action) => {
if (!user) return false; if (!user) return false;
// sysadmin and admin have full access // sysadmin and admin have full access
@@ -79,13 +98,22 @@ export function AuthProvider({ children }) {
const perms = user.permissions; const perms = user.permissions;
if (!perms) return false; if (!perms) return false;
// MQTT is a global flag // crm_orders is derived from crm_customers
if (section === "mqtt") { if (section === "crm_orders") {
return !!perms.mqtt; const cc = perms.crm_customers;
if (!cc) return false;
if (cc.full_access) return true;
if (action === "view") return !!cc.orders_view;
if (action === "edit") return !!cc.orders_edit;
return false;
} }
const sectionPerms = perms[section]; const sectionPerms = perms[section];
if (!sectionPerms) return false; if (!sectionPerms) return false;
// crm_customers.full_access grants everything in that section
if (section === "crm_customers" && sectionPerms.full_access) return true;
return !!sectionPerms[action]; return !!sectionPerms[action];
}; };

View File

@@ -0,0 +1,141 @@
import emailIconRaw from "../../assets/comms/email.svg?raw";
import inpersonIconRaw from "../../assets/comms/inperson.svg?raw";
import noteIconRaw from "../../assets/comms/note.svg?raw";
import smsIconRaw from "../../assets/comms/sms.svg?raw";
import whatsappIconRaw from "../../assets/comms/whatsapp.svg?raw";
import callIconRaw from "../../assets/comms/call.svg?raw";
import inboundIconRaw from "../../assets/comms/inbound.svg?raw";
import outboundIconRaw from "../../assets/comms/outbound.svg?raw";
import internalIconRaw from "../../assets/comms/internal.svg?raw";
const TYPE_TONES = {
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
whatsapp: { bg: "#dcfce7", color: "#166534" },
call: { bg: "#fef9c3", color: "#854d0e" },
sms: { bg: "#fef3c7", color: "#92400e" },
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
in_person: { bg: "#ede9fe", color: "#5b21b6" },
};
const DIR_TONES = {
inbound: { bg: "#2c1a1a", color: "#ef4444", title: "Inbound" },
outbound: { bg: "#13261a", color: "#16a34a", title: "Outbound" },
internal: { bg: "#102335", color: "#4dabf7", title: "Internal" },
};
function IconWrap({ title, bg, color, size = 22, children }) {
return (
<span
title={title}
style={{
width: size,
height: size,
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: bg,
color,
flexShrink: 0,
}}
>
{children}
</span>
);
}
function InlineRawSvg({ raw, size = 12, forceRootFill = true }) {
if (!raw) return null;
let normalized = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/#000000/gi, "currentColor")
.replace(/#000\b/gi, "currentColor")
.replace(/\sfill="(?!none|currentColor|url\()[^"]*"/gi, ' fill="currentColor"')
.replace(/\sstroke="(?!none|currentColor|url\()[^"]*"/gi, ' stroke="currentColor"')
.replace(/fill\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "fill:currentColor")
.replace(/stroke\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "stroke:currentColor");
normalized = forceRootFill
? normalized.replace(/<svg\b/i, '<svg width="100%" height="100%" fill="currentColor"')
: normalized.replace(/<svg\b/i, '<svg width="100%" height="100%"');
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
display: "inline-flex",
}}
dangerouslySetInnerHTML={{ __html: normalized }}
/>
);
}
const TYPE_ICON_SRC = {
email: emailIconRaw,
whatsapp: whatsappIconRaw,
call: callIconRaw,
sms: smsIconRaw,
note: noteIconRaw,
in_person: inpersonIconRaw,
};
const DIRECTION_ICON_SRC = {
inbound: inboundIconRaw,
outbound: outboundIconRaw,
internal: internalIconRaw,
};
const TYPE_ICON_COLORS = {
note: "#ffffff",
whatsapp: "#06bd00",
call: "#2c2c2c",
sms: "#002981",
};
function TypeSvg({ type }) {
const src = TYPE_ICON_SRC[type];
if (src) {
return <InlineRawSvg raw={src} />;
}
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" };
// Fallback for missing custom icon files (e.g. call.svg).
if (type === "call") {
return <svg {...props}><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.4 19.4 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.8.4 1.6.7 2.3a2 2 0 0 1-.5 2.1L8 9.3a16 16 0 0 0 6.7 6.7l1.2-1.2a2 2 0 0 1 2.1-.5c.7.3 1.5.6 2.3.7A2 2 0 0 1 22 16.9Z"/></svg>;
}
return <svg {...props}><path d="M6 4h9l3 3v13H6z"/><path d="M15 4v4h4"/><path d="M9 13h6"/></svg>;
}
function DirSvg({ direction }) {
const src = DIRECTION_ICON_SRC[direction];
if (src) {
return <InlineRawSvg raw={src} forceRootFill={false} />;
}
const props = { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" };
if (direction === "inbound") return <svg {...props}><path d="M20 4 9 15"/><path d="M9 6v9h9"/></svg>;
if (direction === "outbound") return <svg {...props}><path d="m4 20 11-11"/><path d="M15 18V9H6"/></svg>;
return <svg {...props}><path d="M7 7h10"/><path d="m13 3 4 4-4 4"/><path d="M17 17H7"/><path d="m11 13-4 4 4 4"/></svg>;
}
export function getCommTypeTone(type) {
return TYPE_TONES[type] || TYPE_TONES.note;
}
export function CommTypeIconBadge({ type, size = 22 }) {
const tone = getCommTypeTone(type);
const iconColor = TYPE_ICON_COLORS[type] || tone.color;
return (
<IconWrap title={type} bg={tone.bg} color={iconColor} size={size}>
<TypeSvg type={type} />
</IconWrap>
);
}
export function CommDirectionIcon({ direction, size = 22 }) {
const tone = DIR_TONES[direction] || DIR_TONES.internal;
return (
<span title={tone.title} style={{ color: tone.color, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<DirSvg direction={direction} />
</span>
);
}

View File

@@ -0,0 +1,928 @@
/**
* ComposeEmailModal
* A full-featured email compose modal using Quill.js (loaded from CDN).
* Features: To / CC / Subject, WYSIWYG rich body, Ctrl+V image paste, file attachments.
*
* Props:
* open boolean
* onClose () => void
* defaultTo string (pre-fill To field)
* defaultSubject string
* customerId string | null (linked customer, optional)
* onSent (entry) => void (called after successful send)
*/
import { useState, useEffect, useRef, useCallback } from "react";
import api from "../../api/client";
// ── Quill loader ──────────────────────────────────────────────────────────────
let _quillReady = false;
let _quillCallbacks = [];
function loadQuill(cb) {
if (_quillReady) { cb(); return; }
_quillCallbacks.push(cb);
if (document.getElementById("__quill_css__")) return; // already loading
// CSS
const link = document.createElement("link");
link.id = "__quill_css__";
link.rel = "stylesheet";
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
document.head.appendChild(link);
// JS
const script = document.createElement("script");
script.id = "__quill_js__";
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
script.onload = () => {
_quillReady = true;
_quillCallbacks.forEach((fn) => fn());
_quillCallbacks = [];
};
document.head.appendChild(script);
}
// ── Attachment item ───────────────────────────────────────────────────────────
function AttachmentPill({ name, size, onRemove }) {
const kb = size ? ` (${Math.ceil(size / 1024)} KB)` : "";
return (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-xs"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-secondary)" }}
>
<span className="truncate" style={{ maxWidth: 160 }}>{name}{kb}</span>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
>
×
</button>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ComposeEmailModal({
open,
onClose,
defaultTo = "",
defaultSubject = "",
defaultFromAccount = "",
requireFromAccount = true,
defaultServerAttachments = [],
customerId = null,
onSent,
}) {
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState("");
const [subject, setSubject] = useState(defaultSubject);
const [fromAccount, setFromAccount] = useState(defaultFromAccount || "");
const [mailAccounts, setMailAccounts] = useState([]);
const [attachments, setAttachments] = useState([]); // { file: File, name: string, size?: number }[]
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [quillLoaded, setQuillLoaded] = useState(_quillReady);
const [showServerFiles, setShowServerFiles] = useState(false);
const [serverFiles, setServerFiles] = useState([]);
const [serverFilesLoading, setServerFilesLoading] = useState(false);
const [serverFileSearch, setServerFileSearch] = useState("");
const [serverFileType, setServerFileType] = useState("all");
const [previewFile, setPreviewFile] = useState(null); // { path, filename, mime_type }
const [editorPreviewDark, setEditorPreviewDark] = useState(true);
const editorRef = useRef(null);
const quillRef = useRef(null);
const fileInputRef = useRef(null);
// Reset fields when opened
useEffect(() => {
if (open) {
setTo(defaultTo);
setCc("");
setSubject(defaultSubject);
setFromAccount(defaultFromAccount || "");
setAttachments([]);
setError("");
setEditorPreviewDark(true);
}
}, [open, defaultTo, defaultSubject, defaultFromAccount]);
useEffect(() => {
if (!open) return;
let cancelled = false;
api.get("/crm/comms/email/accounts")
.then((data) => {
if (cancelled) return;
const accounts = data.accounts || [];
setMailAccounts(accounts);
if (!defaultFromAccount && accounts.length === 1) {
setFromAccount(accounts[0].key);
}
})
.catch(() => {
if (!cancelled) setMailAccounts([]);
});
return () => { cancelled = true; };
}, [open, defaultFromAccount]);
// Load Quill
useEffect(() => {
if (!open) return;
loadQuill(() => setQuillLoaded(true));
}, [open]);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Init Quill editor
useEffect(() => {
if (!open || !quillLoaded || !editorRef.current) return;
if (quillRef.current) return; // already initialized
const quill = new window.Quill(editorRef.current, {
theme: "snow",
placeholder: "Write your message...",
modules: {
toolbar: [
[{ size: ["small", false, "large", "huge"] }],
["bold", "italic", "underline", "strike"],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { indent: "-1" }, { indent: "+1" }],
["code-block", "blockquote", "link", "image"],
["clean"],
],
clipboard: { matchVisual: false },
},
});
quillRef.current = quill;
// Force single-row toolbar via JS (defeats Quill's float-based layout)
const container = editorRef.current;
const toolbar = container.querySelector(".ql-toolbar");
const qlContainer = container.querySelector(".ql-container");
if (toolbar && qlContainer) {
// Make editorRef a flex column
container.style.cssText += ";display:flex!important;flex-direction:column!important;";
// Toolbar: single flex row
toolbar.style.cssText += ";display:flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;overflow:visible!important;padding:3px 8px!important;";
// Kill floats on every .ql-formats, button, .ql-picker
toolbar.querySelectorAll(".ql-formats").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;flex-wrap:nowrap!important;align-items:center!important;flex-shrink:0!important;";
});
toolbar.querySelectorAll("button").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;width:24px!important;height:24px!important;";
});
toolbar.querySelectorAll(".ql-picker").forEach(el => {
el.style.cssText += ";float:none!important;display:inline-flex!important;align-items:center!important;flex-shrink:0!important;height:24px!important;overflow:visible!important;";
});
// Editor container fills remaining space
qlContainer.style.cssText += ";flex:1!important;min-height:0!important;overflow:hidden!important;";
qlContainer.querySelector(".ql-editor").style.cssText += ";height:100%!important;overflow-y:auto!important;";
}
// Keep color picker icons in sync with currently selected text/highlight colors.
const syncPickerIndicators = () => {
if (!toolbar) return;
const formats = quill.getFormat();
const textColor = typeof formats.color === "string" ? formats.color : "";
const highlightColor = typeof formats.background === "string" ? formats.background : "";
toolbar.style.setProperty("--ql-current-color", textColor || "var(--text-secondary)");
toolbar.style.setProperty("--ql-current-bg", highlightColor || "var(--bg-input)");
toolbar.classList.toggle("ql-has-bg-color", Boolean(highlightColor));
const colorLabel =
toolbar.querySelector(".ql-picker.ql-color-picker.ql-color .ql-picker-label") ||
toolbar.querySelector(".ql-picker.ql-color .ql-picker-label");
const bgLabel =
toolbar.querySelector(".ql-picker.ql-color-picker.ql-background .ql-picker-label") ||
toolbar.querySelector(".ql-picker.ql-background .ql-picker-label");
if (colorLabel) {
colorLabel.style.boxShadow = `inset 0 -3px 0 ${textColor || "var(--text-secondary)"}`;
colorLabel.querySelectorAll(".ql-stroke, .ql-stroke-miter").forEach((el) => {
el.style.setProperty("stroke", textColor || "var(--text-secondary)", "important");
});
colorLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
el.style.setProperty("fill", textColor || "var(--text-secondary)", "important");
});
let swatch = colorLabel.querySelector(".compose-picker-swatch");
if (!swatch) {
swatch = document.createElement("span");
swatch.className = "compose-picker-swatch";
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
colorLabel.appendChild(swatch);
}
swatch.style.background = textColor || "var(--text-secondary)";
}
if (bgLabel) {
bgLabel.style.boxShadow = highlightColor
? `inset 0 -7px 0 ${highlightColor}`
: "inset 0 -7px 0 transparent";
bgLabel.style.borderBottom = "1px solid var(--border-secondary)";
bgLabel.querySelectorAll(".ql-fill, .ql-color-label").forEach((el) => {
el.style.setProperty("fill", "var(--text-secondary)", "important");
});
let swatch = bgLabel.querySelector(".compose-picker-swatch");
if (!swatch) {
swatch = document.createElement("span");
swatch.className = "compose-picker-swatch";
swatch.style.cssText = "position:absolute;right:2px;top:2px;width:7px;height:7px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);pointer-events:none;";
bgLabel.appendChild(swatch);
}
swatch.style.background = highlightColor || "transparent";
swatch.style.borderColor = highlightColor ? "rgba(255,255,255,0.35)" : "var(--border-secondary)";
}
};
quill.on("selection-change", syncPickerIndicators);
quill.on("text-change", syncPickerIndicators);
quill.on("editor-change", syncPickerIndicators);
syncPickerIndicators();
// Handle Ctrl+V image paste
quill.root.addEventListener("paste", (e) => {
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = (ev) => {
const range = quill.getSelection(true);
quill.insertEmbed(range.index, "image", ev.target.result);
};
reader.readAsDataURL(blob);
break;
}
}
});
return () => {
quill.off("selection-change", syncPickerIndicators);
quill.off("text-change", syncPickerIndicators);
quill.off("editor-change", syncPickerIndicators);
quillRef.current = null;
};
}, [open, quillLoaded]);
const getContent = useCallback(() => {
const q = quillRef.current;
if (!q) return { html: "", text: "" };
const html = q.root.innerHTML;
const text = q.getText().trim();
return { html, text };
}, []);
const handleFileAdd = (files) => {
const newFiles = Array.from(files).map((f) => ({ file: f, name: f.name }));
setAttachments((prev) => [...prev, ...newFiles]);
};
useEffect(() => {
if (!open || !defaultServerAttachments?.length) return;
let cancelled = false;
(async () => {
for (const f of defaultServerAttachments) {
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
if (!resp.ok) continue;
const blob = await resp.blob();
if (cancelled) return;
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
setAttachments((prev) => {
if (prev.some((a) => a.name === f.filename)) return prev;
return [...prev, { file, name: f.filename }];
});
} catch {
// ignore pre-attachment failures
}
}
})();
return () => { cancelled = true; };
}, [open, defaultServerAttachments]);
// Open server file picker and load files for this customer
const openServerFiles = async () => {
setShowServerFiles(true);
setServerFileSearch("");
setServerFileType("all");
if (serverFiles.length > 0) return; // already loaded
setServerFilesLoading(true);
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/browse-all?customer_id=${customerId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error("Failed to load files");
const data = await resp.json();
setServerFiles((data.items || []).filter((f) => !f.is_dir));
} catch {
setServerFiles([]);
} finally {
setServerFilesLoading(false);
}
};
// Attach a server file by downloading it as a Blob and adding to attachments
const attachServerFile = async (f) => {
try {
const token = localStorage.getItem("access_token");
const resp = await fetch(`/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`);
if (!resp.ok) throw new Error("Download failed");
const blob = await resp.blob();
const file = new File([blob], f.filename, { type: f.mime_type || "application/octet-stream" });
setAttachments((prev) => [...prev, { file, name: f.filename }]);
} catch (err) {
setError(`Could not attach ${f.filename}: ${err.message}`);
}
setShowServerFiles(false);
};
// Determine file type category for filter
function getFileCategory(f) {
const mime = f.mime_type || "";
const sub = f.subfolder || "";
if (sub === "quotations") return "quotation";
if (mime === "application/pdf" || sub.includes("invoic") || sub.includes("document")) return "document";
if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/") || sub.includes("media")) return "media";
return "document";
}
const handleSend = async () => {
const { html, text } = getContent();
const toClean = to.trim();
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toClean);
if (requireFromAccount && !fromAccount) { setError("Please select a sender account."); return; }
if (!to.trim()) { setError("Please enter a recipient email address."); return; }
if (!emailOk) { setError("Please enter a valid recipient email address."); return; }
if (!subject.trim()) { setError("Please enter a subject."); return; }
if (!text && !html.replace(/<[^>]*>/g, "").trim()) { setError("Please write a message."); return; }
setError("");
setSending(true);
try {
const ccList = cc.split(",").map((s) => s.trim()).filter(Boolean);
const token = localStorage.getItem("access_token");
const fd = new FormData();
if (customerId) fd.append("customer_id", customerId);
if (fromAccount) fd.append("from_account", fromAccount);
fd.append("to", to.trim());
fd.append("subject", subject.trim());
fd.append("body", text);
fd.append("body_html", html);
fd.append("cc", JSON.stringify(ccList));
for (const { file } of attachments) {
fd.append("files", file, file.name);
}
const resp = await fetch("/api/crm/comms/email/send", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Server error ${resp.status}`);
}
const data = await resp.json();
if (onSent) onSent(data.entry || data);
onClose();
} catch (err) {
setError(err.message || "Failed to send email.");
} finally {
setSending(false);
}
};
if (!open) return null;
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "7px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 80,
}}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border-primary)" }}
>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
New Email
</h2>
<button
type="button"
onClick={onClose}
className="cursor-pointer hover:opacity-70"
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, lineHeight: 1 }}
>
×
</button>
</div>
{/* Fields */}
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Send As</label>
<select
className="compose-email-input"
style={inputStyle}
value={fromAccount}
onChange={(e) => setFromAccount(e.target.value)}
>
<option value="">Select sender...</option>
{mailAccounts.filter((a) => a.allow_send).map((a) => (
<option key={a.key} value={a.key}>{a.label} ({a.email})</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>To</label>
<input
className="compose-email-input"
style={inputStyle}
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@example.com"
type="email"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>CC</label>
<input
className="compose-email-input"
style={inputStyle}
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc1@example.com, cc2@..."
/>
</div>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Subject</label>
<input
className="compose-email-input"
style={inputStyle}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Email subject"
/>
</div>
</div>
{/* Quill Editor — Quill injects toolbar+editor into editorRef */}
<div className="quill-compose-wrapper" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0, position: "relative" }}>
<div style={{ position: "absolute", top: 8, right: 12, zIndex: 20 }}>
<button
type="button"
onClick={() => setEditorPreviewDark((v) => !v)}
title={editorPreviewDark ? "Switch to light preview" : "Switch to dark preview"}
style={{
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
border: "1px solid rgba(128,128,128,0.4)",
backgroundColor: editorPreviewDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
color: editorPreviewDark ? "#e0e0e0" : "#333",
backdropFilter: "blur(4px)",
}}
>
{editorPreviewDark ? "☀ Light" : "🌙 Dark"}
</button>
</div>
{quillLoaded ? (
<div ref={editorRef} style={{ flex: 1, minHeight: 0 }} />
) : (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>
Loading editor...
</div>
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-5 py-3 flex flex-wrap gap-2" style={{ borderTop: "1px solid var(--border-secondary)" }}>
{attachments.map((a, i) => (
<AttachmentPill
key={i}
name={a.name}
size={a.file.size}
onRemove={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
/>
))}
</div>
)}
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderTop: "1px solid var(--border-primary)" }}
>
<div className="flex items-center gap-3">
{customerId && (
<button
type="button"
onClick={openServerFiles}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
🗂 Attach from Server
</button>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
📎 Attach
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => handleFileAdd(e.target.files)}
/>
<button
type="button"
onClick={() => {
const sig = localStorage.getItem("mail_signature") || "";
if (!sig.trim() || sig === "<p><br></p>") return;
const q = quillRef.current;
if (!q) return;
const current = q.root.innerHTML;
q.clipboard.dangerouslyPasteHTML(current + '<p><br></p><hr/><div class="mail-sig">' + sig + "</div>");
}}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
title="Append signature to message"
>
Add Signature
</button>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Tip: Paste images directly with Ctrl+V
</span>
</div>
<div className="flex items-center gap-3">
{error && (
<span className="text-xs" style={{ color: "var(--danger-text)" }}>{error}</span>
)}
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
type="button"
onClick={handleSend}
disabled={sending}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{
backgroundColor: "var(--btn-primary)",
color: "var(--text-white)",
opacity: sending ? 0.7 : 1,
}}
>
{sending ? "Sending..." : "Send"}
</button>
</div>
</div>
</div>
{/* Server File Picker Modal */}
{showServerFiles && (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
onClick={() => setShowServerFiles(false)}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: 540,
maxHeight: "70vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 16px 48px rgba(0,0,0,0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Attach File from Server</h3>
<button type="button" onClick={() => setShowServerFiles(false)} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 20, cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
{/* Search + Type filter */}
<div className="px-4 py-3 flex gap-2" style={{ borderBottom: "1px solid var(--border-secondary)", flexShrink: 0 }}>
<input
type="text"
placeholder="Search by filename..."
value={serverFileSearch}
onChange={(e) => setServerFileSearch(e.target.value)}
autoFocus
className="flex-1 px-3 py-1.5 text-sm rounded border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
/>
<select
value={serverFileType}
onChange={(e) => setServerFileType(e.target.value)}
className="px-3 py-1.5 text-sm rounded border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
>
<option value="all">All types</option>
<option value="document">Documents</option>
<option value="quotation">Quotations</option>
<option value="media">Media</option>
</select>
</div>
{/* File list */}
<div style={{ overflowY: "auto", flex: 1 }}>
{serverFilesLoading && (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>Loading files...</div>
)}
{!serverFilesLoading && serverFiles.length === 0 && (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files found for this customer.</div>
)}
{!serverFilesLoading && (() => {
const token = localStorage.getItem("access_token");
const filtered = serverFiles.filter((f) => {
const matchSearch = !serverFileSearch.trim() || f.filename.toLowerCase().includes(serverFileSearch.toLowerCase());
const matchType = serverFileType === "all" || getFileCategory(f) === serverFileType;
return matchSearch && matchType && !f.is_dir;
});
if (filtered.length === 0 && serverFiles.length > 0) {
return <div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>No files match your search.</div>;
}
return filtered.map((f) => {
const alreadyAttached = attachments.some((a) => a.name === f.filename);
const cat = getFileCategory(f);
const catColors = {
quotation: { bg: "#1a2d1e", color: "#6aab7a" },
document: { bg: "#1e1a2d", color: "#a78bfa" },
media: { bg: "#2d2a1a", color: "#c9a84c" },
};
const c = catColors[cat] || catColors.document;
const kb = f.size > 0 ? `${(f.size / 1024).toFixed(0)} KB` : "";
const isImage = (f.mime_type || "").startsWith("image/");
const thumbUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(f.path)}&token=${encodeURIComponent(token)}`;
// Thumbnail / icon
const thumb = isImage ? (
<img
src={thumbUrl}
alt=""
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 5, flexShrink: 0, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
/>
) : (
<div
onClick={(e) => { e.stopPropagation(); setPreviewFile(f); }}
style={{ width: 40, height: 40, borderRadius: 5, flexShrink: 0, backgroundColor: c.bg, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, cursor: "zoom-in", border: "1px solid var(--border-secondary)" }}
>
{cat === "quotation" ? "🧾" : cat === "media" ? "🎵" : "📄"}
</div>
);
return (
<div
key={f.path}
onClick={() => !alreadyAttached && attachServerFile(f)}
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "8px 16px",
borderBottom: "1px solid var(--border-secondary)",
cursor: alreadyAttached ? "default" : "pointer",
opacity: alreadyAttached ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!alreadyAttached) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
{thumb}
<span className="flex-1 text-sm truncate" style={{ color: "var(--text-primary)" }}>{f.filename}</span>
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>{kb}</span>
{alreadyAttached
? <span className="text-xs flex-shrink-0" style={{ color: "var(--accent)" }}>Attached</span>
: <span style={{ fontSize: 11, padding: "2px 7px", borderRadius: 10, backgroundColor: c.bg, color: c.color, fontWeight: 500, flexShrink: 0, whiteSpace: "nowrap" }}>{cat}</span>
}
</div>
);
});
})()}
</div>
</div>
</div>
)}
{/* File preview modal */}
{previewFile && (() => {
const token = localStorage.getItem("access_token");
const fileUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(previewFile.path)}&token=${encodeURIComponent(token)}`;
const mime = previewFile.mime_type || "";
const isImage = mime.startsWith("image/");
const isPdf = mime === "application/pdf";
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1200, backgroundColor: "rgba(0,0,0,0.8)", display: "flex", alignItems: "center", justifyContent: "center", padding: 40 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ position: "relative", maxWidth: "90vw", maxHeight: "85vh", display: "flex", flexDirection: "column", borderRadius: 10, overflow: "hidden", backgroundColor: "var(--bg-card)", boxShadow: "0 20px 60px rgba(0,0,0,0.6)" }}
onClick={(e) => e.stopPropagation()}
>
{/* Preview header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 400 }}>{previewFile.filename}</span>
<div style={{ display: "flex", gap: 8, marginLeft: 16, flexShrink: 0 }}>
<a href={fileUrl} download={previewFile.filename} style={{ fontSize: 12, color: "var(--accent)", textDecoration: "none" }}>Download</a>
<button onClick={() => setPreviewFile(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 20, lineHeight: 1 }}>×</button>
</div>
</div>
{/* Preview body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, backgroundColor: "var(--bg-primary)" }}>
{isImage && <img src={fileUrl} alt={previewFile.filename} style={{ maxWidth: "80vw", maxHeight: "70vh", objectFit: "contain", borderRadius: 6 }} />}
{isPdf && <iframe src={fileUrl} title={previewFile.filename} style={{ width: "75vw", height: "70vh", border: "none", borderRadius: 6 }} />}
{!isImage && !isPdf && (
<div style={{ textAlign: "center", color: "var(--text-muted)" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📄</div>
<div className="text-sm">{previewFile.filename}</div>
<a href={fileUrl} download={previewFile.filename} className="text-sm" style={{ color: "var(--accent)", marginTop: 8, display: "inline-block" }}>Download to view</a>
</div>
)}
</div>
</div>
</div>
);
})()}
{/* Quill snow theme overrides — layout handled via JS, only cosmetics here */}
<style>{`
.quill-compose-wrapper .ql-toolbar.ql-snow::after { display: none !important; }
.quill-compose-wrapper .ql-toolbar.ql-snow {
background: var(--bg-card-hover) !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
border-bottom: 1px solid var(--border-secondary) !important;
position: relative !important;
z-index: 3 !important;
overflow: visible !important;
}
.quill-compose-wrapper .ql-toolbar .ql-formats {
gap: 1px !important;
margin: 0 10px 0 0 !important;
padding: 0 10px 0 0 !important;
border-right: 1px solid #4b5563 !important;
position: relative !important;
}
.quill-compose-wrapper .ql-toolbar .ql-formats:last-child {
border-right: none !important;
padding-right: 0 !important;
margin-right: 0 !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker,
.quill-compose-wrapper .ql-toolbar .ql-picker-label,
.quill-compose-wrapper .ql-toolbar .ql-picker-item {
color: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker-label {
position: relative !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-size .ql-picker-label::before {
color: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar button:hover,
.quill-compose-wrapper .ql-toolbar button.ql-active { background: var(--bg-primary) !important; }
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label .ql-fill {
stroke: var(--ql-current-color, var(--text-secondary)) !important;
fill: var(--ql-current-color, var(--text-secondary)) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-fill {
fill: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label .ql-stroke {
stroke: var(--border-secondary) !important;
}
/* Dropdowns: keep anchored to picker and above editor scroll area */
.quill-compose-wrapper .ql-toolbar .ql-picker {
position: relative !important;
}
.quill-compose-wrapper .ql-picker-options {
position: absolute !important;
z-index: 40 !important;
background: var(--bg-card) !important;
border: 1px solid var(--border-primary) !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.45) !important;
border-radius: 6px !important;
max-height: 220px !important;
overflow-y: auto !important;
}
.quill-compose-wrapper .ql-stroke { stroke: var(--text-secondary) !important; }
.quill-compose-wrapper .ql-toolbar button:hover .ql-stroke,
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
.quill-compose-wrapper .ql-fill { fill: var(--text-secondary) !important; }
.quill-compose-wrapper .ql-toolbar button:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar button.ql-active .ql-fill { fill: var(--accent) !important; }
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-stroke,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color .ql-picker-label:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-color.ql-expanded .ql-picker-label .ql-fill {
stroke: var(--ql-current-color, var(--text-secondary)) !important;
fill: var(--ql-current-color, var(--text-secondary)) !important;
}
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background .ql-picker-label:hover .ql-fill,
.quill-compose-wrapper .ql-toolbar .ql-picker.ql-background.ql-expanded .ql-picker-label .ql-fill {
fill: var(--text-secondary) !important;
}
.quill-compose-wrapper .ql-container.ql-snow {
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
font-size: 14px !important;
}
.quill-compose-wrapper .ql-editor {
color: ${editorPreviewDark ? "var(--text-primary)" : "#1a1a1a"} !important;
background: ${editorPreviewDark ? "var(--bg-input)" : "#ffffff"} !important;
}
.quill-compose-wrapper .ql-editor.ql-blank::before {
color: ${editorPreviewDark ? "var(--text-muted)" : "#6b7280"} !important;
font-style: normal !important;
}
.quill-compose-wrapper .ql-editor blockquote {
border-left: 3px solid ${editorPreviewDark ? "var(--border-primary)" : "#d1d5db"} !important;
color: ${editorPreviewDark ? "var(--text-secondary)" : "#6b7280"} !important;
padding-left: 12px !important;
}
.compose-email-input {
border: 1px solid var(--border-primary) !important;
box-shadow: none !important;
background-color: var(--bg-input) !important;
color: var(--text-primary) !important;
}
.compose-email-input:focus {
border-color: var(--border-primary) !important;
box-shadow: none !important;
outline: none !important;
}
.compose-email-input:-webkit-autofill,
.compose-email-input:-webkit-autofill:hover,
.compose-email-input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px var(--bg-input) inset !important;
-webkit-text-fill-color: var(--text-primary) !important;
border: 1px solid var(--border-primary) !important;
transition: background-color 9999s ease-out 0s;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,669 @@
/**
* MailViewModal
* Full email view with:
* - Dark-themed iframe body
* - ESC / click-outside to close
* - Save inline images (via JSON endpoint) or attachments (re-fetched from IMAP)
* to customer's Nextcloud media folder
*
* Props:
* open boolean
* onClose () => void
* entry CommInDB
* customer customer object | null
* onReply (defaultTo: string) => void
*/
import { useRef, useEffect, useState } from "react";
const SUBFOLDERS = ["received_media", "documents", "sent_media", "photos"];
// ── Add Customer mini modal ────────────────────────────────────────────────────
function AddCustomerModal({ email, onClose, onCreated }) {
const [name, setName] = useState("");
const [surname, setSurname] = useState("");
const [organization, setOrganization] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleSave = async () => {
if (!name.trim()) { setErr("Please enter a first name."); return; }
setSaving(true);
setErr("");
try {
const token = localStorage.getItem("access_token");
const body = {
name: name.trim(),
surname: surname.trim(),
organization: organization.trim(),
language: "en",
contacts: [{ type: "email", label: "Email", value: email, primary: true }],
};
const resp = await fetch("/api/crm/customers", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
const data = await resp.json();
onCreated(data);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 10,
padding: 24,
width: 400,
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
Add Customer
</h3>
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Adding <strong style={{ color: "var(--accent)" }}>{email}</strong> as a new customer.
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>First Name *</label>
<input style={inputStyle} value={name} onChange={(e) => setName(e.target.value)} placeholder="First name" autoFocus />
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Last Name</label>
<input style={inputStyle} value={surname} onChange={(e) => setSurname(e.target.value)} placeholder="Last name" />
</div>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Organization</label>
<input style={inputStyle} value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder="Church, school, etc." />
</div>
<div className="mb-4">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>Email</label>
<input style={{ ...inputStyle, opacity: 0.6, cursor: "not-allowed" }} value={email} readOnly />
</div>
{err && <p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Add Customer"}
</button>
</div>
</div>
</div>
);
}
// ── Save-to-DB mini modal ─────────────────────────────────────────────────────
function SaveModal({ item, commId, onClose }) {
// item: { type: "inline"|"attachment", filename, mime_type, dataUri?, attachmentIndex? }
const [filename, setFilename] = useState(item.filename || "file");
const [subfolder, setSubfolder] = useState("received_media");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
// ESC to close
useEffect(() => {
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleSave = async () => {
if (!filename.trim()) { setErr("Please enter a filename."); return; }
setSaving(true);
setErr("");
try {
const token = localStorage.getItem("access_token");
if (item.type === "inline") {
// JSON body — avoids form multipart size limits for large data URIs
const resp = await fetch(`/api/crm/comms/email/${commId}/save-inline`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data_uri: item.dataUri,
filename: filename.trim(),
subfolder,
mime_type: item.mime_type,
}),
});
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
} else {
// Attachment: re-fetched from IMAP server-side
const fd = new FormData();
fd.append("filename", filename.trim());
fd.append("subfolder", subfolder);
const resp = await fetch(
`/api/crm/comms/email/${commId}/save-attachment/${item.attachmentIndex}`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
}
);
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
throw new Error(e.detail || `Error ${resp.status}`);
}
}
setDone(true);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
border: "1px solid",
borderRadius: 6,
padding: "6px 10px",
fontSize: 13,
width: "100%",
outline: "none",
};
return (
<div
style={{
position: "fixed", inset: 0, zIndex: 1100,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
// Do NOT close on backdrop click user must use Cancel
>
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 10,
padding: 24,
width: 380,
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
}}
>
{done ? (
<div className="text-center py-4">
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>Saved successfully</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
File stored in <strong>{subfolder}/</strong>
</p>
<button
onClick={onClose}
className="mt-4 px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Done
</button>
</div>
) : (
<>
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Save to Customer Media
</h3>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
Filename
</label>
<input style={inputStyle} value={filename} onChange={(e) => setFilename(e.target.value)} />
</div>
<div className="mb-4">
<label className="text-xs font-medium block mb-1" style={{ color: "var(--text-secondary)" }}>
Folder
</label>
<select value={subfolder} onChange={(e) => setSubfolder(e.target.value)} style={inputStyle}>
{SUBFOLDERS.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
{err && (
<p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{err}</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function MailViewModal({ open, onClose, entry, customer, onReply, onCustomerAdded }) {
const iframeRef = useRef(null);
const [saveItem, setSaveItem] = useState(null);
const [inlineImages, setInlineImages] = useState([]);
const [bodyDark, setBodyDark] = useState(true);
const [showAddCustomer, setShowAddCustomer] = useState(false);
const [addedCustomer, setAddedCustomer] = useState(null);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Extract inline images from HTML body
useEffect(() => {
if (!entry?.body_html) { setInlineImages([]); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(entry.body_html, "text/html");
const imgs = Array.from(doc.querySelectorAll("img[src^='data:']"));
const found = imgs.map((img, i) => {
const src = img.getAttribute("src");
const mimeMatch = src.match(/^data:([^;]+);/);
const mime = mimeMatch ? mimeMatch[1] : "image/png";
const ext = mime.split("/")[1] || "png";
return {
type: "inline",
filename: `inline-image-${i + 1}.${ext}`,
mime_type: mime,
dataUri: src,
};
});
setInlineImages(found);
}, [entry]);
// Reset dark mode when new entry opens
useEffect(() => { setBodyDark(true); }, [entry]);
// Reset addedCustomer when a new entry opens
useEffect(() => { setAddedCustomer(null); }, [entry]);
if (!open || !entry) return null;
const isInbound = entry.direction === "inbound";
const fromLabel = isInbound
? (entry.from_addr || customer?.name || "Unknown Sender")
: "Me";
const toLabel = Array.isArray(entry.to_addrs)
? entry.to_addrs.join(", ")
: (entry.to_addrs || "");
const hasHtml = !!entry.body_html && entry.body_html.trim().length > 0;
const attachments = Array.isArray(entry.attachments) ? entry.attachments : [];
const canSave = !!entry.customer_id;
const handleReply = () => {
const replyTo = isInbound ? (entry.from_addr || "") : toLabel;
if (onReply) onReply(replyTo, entry.mail_account || "");
onClose();
};
const iframeDoc = hasHtml
? entry.body_html
: `<pre style="font-family:inherit;white-space:pre-wrap;margin:0">${(entry.body || "").replace(/</g, "&lt;")}</pre>`;
return (
<>
{/* Backdrop — click outside closes */}
<div
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 60,
}}
onClick={onClose}
>
{/* Modal box */}
<div
style={{
backgroundColor: "var(--bg-card)",
border: "1px solid var(--border-primary)",
borderRadius: 12,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 className="text-base font-semibold truncate" style={{ color: "var(--text-heading)" }}>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>(no subject)</span>}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
</p>
</div>
<button
type="button"
onClick={onClose}
className="cursor-pointer hover:opacity-70 ml-4 flex-shrink-0"
style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, lineHeight: 1 }}
>
×
</button>
</div>
{/* Meta row */}
<div className="px-5 py-3" style={{ borderBottom: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}>
<div className="flex flex-wrap items-center gap-y-1 text-xs" style={{ gap: "0 0" }}>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>From: </span>
<span style={{ color: "var(--text-primary)" }}>{fromLabel}</span>
{isInbound && entry.from_addr && customer && (
<span style={{ color: "var(--text-muted)", marginLeft: 4 }}>({entry.from_addr})</span>
)}
</span>
{toLabel && (
<>
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>To: </span>
<span style={{ color: "var(--text-primary)" }}>{toLabel}</span>
</span>
</>
)}
{customer && (
<>
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span style={{ paddingRight: 12 }}>
<span style={{ color: "var(--text-muted)" }}>Customer: </span>
<span style={{ color: "var(--accent)" }}>{customer.name}</span>
{customer.organization && (
<span style={{ color: "var(--text-muted)" }}> · {customer.organization}</span>
)}
</span>
</>
)}
<span style={{ color: "var(--border-primary)", paddingRight: 12 }}>|</span>
<span
className="px-2 py-0.5 rounded-full capitalize"
style={{
backgroundColor: isInbound ? "var(--danger-bg)" : "#dcfce7",
color: isInbound ? "var(--danger-text)" : "#166534",
}}
>
{entry.direction}
</span>
</div>
</div>
{/* Body — dark iframe with dark/light toggle */}
<div className="flex-1 overflow-hidden" style={{ position: "relative" }}>
{/* Dark / Light toggle */}
<div style={{ position: "absolute", top: 8, right: 20, zIndex: 10 }}>
<button
type="button"
onClick={() => setBodyDark((v) => !v)}
title={bodyDark ? "Switch to light mode" : "Switch to dark mode"}
style={{
padding: "4px 10px", fontSize: 11, borderRadius: 6, cursor: "pointer",
border: "1px solid rgba(128,128,128,0.4)",
backgroundColor: bodyDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.7)",
color: bodyDark ? "#e0e0e0" : "#333",
backdropFilter: "blur(4px)",
transition: "all 0.15s",
}}
>
{bodyDark ? "☀ Light" : "🌙 Dark"}
</button>
</div>
<iframe
ref={iframeRef}
sandbox="allow-same-origin allow-popups"
referrerPolicy="no-referrer"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
srcDoc={`<!DOCTYPE html><html><head>
<meta charset="utf-8">
<style>
html, body { margin: 0; padding: 0; background: ${bodyDark ? "#1a1a1a" : "#ffffff"}; color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
body { padding: 16px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.6; }
img { max-width: 100%; height: auto; display: inline-block; }
pre { white-space: pre-wrap; word-break: break-word; }
a { color: ${bodyDark ? "#60a5fa" : "#1d4ed8"}; }
blockquote { border-left: 3px solid ${bodyDark ? "#404040" : "#d1d5db"}; margin: 8px 0; padding-left: 12px; color: ${bodyDark ? "#9ca3af" : "#6b7280"}; }
table { color: ${bodyDark ? "#e0e0e0" : "#1a1a1a"}; }
* { box-sizing: border-box; }
</style>
</head><body>${iframeDoc}</body></html>`}
/>
</div>
{/* Inline images */}
{inlineImages.length > 0 && (
<div
className="px-5 py-3 flex flex-wrap gap-2"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
Inline Images ({inlineImages.length})
</span>
{inlineImages.map((img, i) => (
<div
key={i}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
<span>🖼</span>
<span className="truncate" style={{ maxWidth: 160 }}>{img.filename}</span>
{canSave && (
<button
type="button"
onClick={() => setSaveItem(img)}
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
>
Save
</button>
)}
</div>
))}
</div>
)}
{/* Attachments */}
{attachments.length > 0 && (
<div
className="px-5 py-3 flex flex-wrap gap-2"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span className="text-xs font-medium w-full mb-1" style={{ color: "var(--text-secondary)" }}>
Attachments ({attachments.length})
</span>
{attachments.map((a, i) => {
const name = a.filename || a.name || "file";
const ct = a.content_type || "";
const kb = a.size ? ` · ${Math.ceil(a.size / 1024)} KB` : "";
const icon = ct.startsWith("image/") ? "🖼️" : ct === "application/pdf" ? "📑" : ct.startsWith("video/") ? "🎬" : "📎";
return (
<div
key={i}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs"
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
<span>{icon}</span>
<span className="truncate" style={{ maxWidth: 200 }}>{name}{kb}</span>
{canSave && (
<button
type="button"
onClick={() => setSaveItem({ type: "attachment", filename: name, mime_type: ct, attachmentIndex: i })}
className="cursor-pointer hover:opacity-80 text-xs px-2 py-0.5 rounded"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", marginLeft: 4 }}
>
Save
</button>
)}
</div>
);
})}
</div>
)}
{/* Unknown sender banner */}
{isInbound && !customer && !addedCustomer && entry.from_addr && (
<div
className="px-5 py-3 flex items-center gap-2 text-xs"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span style={{ color: "var(--text-muted)" }}>
<strong style={{ color: "var(--text-secondary)" }}>{entry.from_addr}</strong> is not in your Customer&apos;s list.{" "}
<button
type="button"
onClick={() => setShowAddCustomer(true)}
className="cursor-pointer hover:underline"
style={{ background: "none", border: "none", padding: 0, color: "var(--accent)", fontWeight: 500, fontSize: "inherit" }}
>
Click here to add them.
</button>
</span>
</div>
)}
{isInbound && addedCustomer && (
<div
className="px-5 py-3 flex items-center gap-2 text-xs"
style={{ borderTop: "1px solid var(--border-secondary)", backgroundColor: "var(--bg-primary)", flexShrink: 0 }}
>
<span style={{ color: "var(--success-text, #16a34a)" }}>
<strong>{addedCustomer.name}</strong> has been added as a customer.
</span>
</div>
)}
{/* Footer */}
<div
className="flex items-center justify-end gap-3 px-5 py-4"
style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}
>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Close
</button>
{onReply && (
<button
type="button"
onClick={handleReply}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}
>
Reply
</button>
)}
</div>
</div>
</div>
{/* Save sub-modal */}
{saveItem && (
<SaveModal
item={saveItem}
commId={entry.id}
onClose={() => setSaveItem(null)}
/>
)}
{/* Add Customer sub-modal */}
{showAddCustomer && entry?.from_addr && (
<AddCustomerModal
email={entry.from_addr}
onClose={() => setShowAddCustomer(false)}
onCreated={(newCustomer) => {
setAddedCustomer(newCustomer);
setShowAddCustomer(false);
if (onCustomerAdded) onCustomerAdded(newCustomer);
}}
/>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,579 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CONTACT_TYPES = ["email", "phone", "whatsapp", "other"];
const LANGUAGES = [
{ value: "el", label: "Greek" },
{ value: "en", label: "English" },
{ value: "de", label: "German" },
{ value: "fr", label: "French" },
{ value: "it", label: "Italian" },
];
const TITLES = ["", "Fr.", "Rev.", "Archim.", "Bp.", "Abp.", "Met.", "Mr.", "Mrs.", "Ms.", "Dr.", "Prof."];
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
const CONTACT_TYPE_ICONS = {
email: "📧",
phone: "📞",
whatsapp: "💬",
other: "🔗",
};
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children }) {
return (
<div
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>{title}</h2>
{children}
</div>
);
}
const emptyContact = () => ({ type: "email", label: "", value: "", primary: false });
export default function CustomerForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { user, hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [form, setForm] = useState({
title: "",
name: "",
surname: "",
organization: "",
language: "el",
tags: [],
folder_id: "",
location: { city: "", country: "", region: "" },
contacts: [],
notes: [],
});
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [tagInput, setTagInput] = useState("");
const [newNoteText, setNewNoteText] = useState("");
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/customers/${id}`)
.then((data) => {
setForm({
title: data.title || "",
name: data.name || "",
surname: data.surname || "",
organization: data.organization || "",
language: data.language || "el",
tags: data.tags || [],
folder_id: data.folder_id || "",
location: {
city: data.location?.city || "",
country: data.location?.country || "",
region: data.location?.region || "",
},
contacts: data.contacts || [],
notes: data.notes || [],
});
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
const setLoc = (field, value) => setForm((f) => ({ ...f, location: { ...f.location, [field]: value } }));
// Tags
const addTag = (raw) => {
const tag = raw.trim();
if (tag && !form.tags.includes(tag)) {
set("tags", [...form.tags, tag]);
}
setTagInput("");
};
const removeTag = (tag) => set("tags", form.tags.filter((t) => t !== tag));
// Contacts
const addContact = () => set("contacts", [...form.contacts, emptyContact()]);
const removeContact = (i) => set("contacts", form.contacts.filter((_, idx) => idx !== i));
const setContact = (i, field, value) => {
const updated = form.contacts.map((c, idx) => idx === i ? { ...c, [field]: value } : c);
set("contacts", updated);
};
const setPrimaryContact = (i) => {
const type = form.contacts[i].type;
const updated = form.contacts.map((c, idx) => ({
...c,
primary: c.type === type ? idx === i : c.primary,
}));
set("contacts", updated);
};
// Notes
const addNote = () => {
if (!newNoteText.trim()) return;
const note = {
text: newNoteText.trim(),
by: user?.name || "unknown",
at: new Date().toISOString(),
};
set("notes", [...form.notes, note]);
setNewNoteText("");
};
const removeNote = (i) => {
set("notes", form.notes.filter((_, idx) => idx !== i));
if (editingNoteIdx === i) setEditingNoteIdx(null);
};
const startEditNote = (i) => {
setEditingNoteIdx(i);
setEditingNoteText(form.notes[i].text);
};
const saveEditNote = (i) => {
if (!editingNoteText.trim()) return;
const updated = form.notes.map((n, idx) =>
idx === i ? { ...n, text: editingNoteText.trim(), at: new Date().toISOString() } : n
);
set("notes", updated);
setEditingNoteIdx(null);
};
const buildPayload = () => ({
title: form.title.trim() || null,
name: form.name.trim(),
surname: form.surname.trim() || null,
organization: form.organization.trim() || null,
language: form.language,
tags: form.tags,
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
location: {
city: form.location.city.trim(),
country: form.location.country.trim(),
region: form.location.region.trim(),
},
contacts: form.contacts.filter((c) => c.value.trim()),
notes: form.notes,
});
const handleSave = async () => {
if (!form.name.trim()) { setError("Customer name is required."); return; }
if (!isEdit && !form.folder_id.trim()) { setError("Internal Folder ID is required."); return; }
if (!isEdit && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(form.folder_id.trim().toLowerCase())) {
setError("Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
return;
}
setSaving(true);
setError("");
try {
if (isEdit) {
await api.put(`/crm/customers/${id}`, buildPayload());
navigate(`/crm/customers/${id}`);
} else {
const res = await api.post("/crm/customers", buildPayload());
navigate(`/crm/customers/${res.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await api.delete(`/crm/customers/${id}`);
navigate("/crm/customers");
} catch (err) {
setError(err.message);
setSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
return (
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Customer" : "New Customer"}
</h1>
<div className="flex gap-2">
<button
onClick={() => navigate(isEdit ? `/crm/customers/${id}` : "/crm/customers")}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
)}
</div>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* Basic Info */}
<SectionCard title="Basic Info">
{/* Row 1: Title, Name, Surname */}
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Title">
<select className={inputClass} style={inputStyle} value={form.title}
onChange={(e) => set("title", e.target.value)}>
{TITLES.map((t) => <option key={t} value={t}>{t || "—"}</option>)}
</select>
</Field>
<Field label="Name *">
<input className={inputClass} style={inputStyle} value={form.name}
onChange={(e) => set("name", e.target.value)} placeholder="First name" />
</Field>
<Field label="Surname">
<input className={inputClass} style={inputStyle} value={form.surname}
onChange={(e) => set("surname", e.target.value)} placeholder="Last name" />
</Field>
</div>
{/* Row 2: Organization, Language, Folder ID */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Organization">
<input className={inputClass} style={inputStyle} value={form.organization}
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
</Field>
<Field label="Language">
<select className={inputClass} style={inputStyle} value={form.language}
onChange={(e) => set("language", e.target.value)}>
{LANGUAGES.map((l) => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</Field>
{!isEdit ? (
<Field label="Folder ID *">
<input
className={inputClass}
style={inputStyle}
value={form.folder_id}
onChange={(e) => set("folder_id", e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g, ""))}
placeholder="e.g. saint-john-corfu"
/>
</Field>
) : (
<div>
<div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Folder ID</div>
<div style={{ fontSize: 13, color: "var(--text-primary)", padding: "6px 0" }}>{form.folder_id || "—"}</div>
</div>
)}
</div>
{!isEdit && (
<p className="text-xs" style={{ color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
Lowercase letters, numbers and hyphens only. This becomes the Nextcloud folder name and cannot be changed later.
</p>
)}
{/* Row 3: Tags */}
<div>
<label style={labelStyle}>Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
onClick={() => removeTag(tag)}
title="Click to remove"
>
{tag} ×
</span>
))}
</div>
{/* Preset quick-add tags */}
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_TAGS.filter((t) => !form.tags.includes(t)).map((t) => (
<button
key={t}
type="button"
onClick={() => addTag(t)}
className="px-2 py-0.5 text-xs rounded-full border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
+ {t}
</button>
))}
</div>
<input
className={inputClass}
style={inputStyle}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput);
}
}}
onBlur={() => tagInput.trim() && addTag(tagInput)}
placeholder="Type a custom tag and press Enter or comma..."
/>
</div>
</SectionCard>
{/* Location */}
<SectionCard title="Location">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="City">
<input className={inputClass} style={inputStyle} value={form.location.city}
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
</Field>
<Field label="Country">
<input className={inputClass} style={inputStyle} value={form.location.country}
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
</Field>
<Field label="Region">
<input className={inputClass} style={inputStyle} value={form.location.region}
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region" />
</Field>
</div>
</SectionCard>
{/* Contacts */}
<SectionCard title="Contacts">
{form.contacts.map((c, i) => (
<div
key={i}
className="flex gap-2 mb-2 items-center"
>
<span className="text-base w-6 text-center flex-shrink-0">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
<select
className="px-2 py-2 text-sm rounded-md border w-32 flex-shrink-0"
style={inputStyle}
value={c.type}
onChange={(e) => setContact(i, "type", e.target.value)}
>
{CONTACT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
className="px-2 py-2 text-sm rounded-md border w-28 flex-shrink-0"
style={inputStyle}
value={c.label}
onChange={(e) => setContact(i, "label", e.target.value)}
placeholder="label (e.g. work)"
/>
<input
className={inputClass + " flex-1"}
style={inputStyle}
value={c.value}
onChange={(e) => setContact(i, "value", e.target.value)}
placeholder="value"
/>
<label className="flex items-center gap-1 text-xs flex-shrink-0 cursor-pointer" style={{ color: "var(--text-muted)" }}>
<input
type="radio"
name={`primary-${c.type}`}
checked={!!c.primary}
onChange={() => setPrimaryContact(i)}
className="cursor-pointer"
/>
Primary
</label>
<button
type="button"
onClick={() => removeContact(i)}
className="text-xs cursor-pointer hover:opacity-70 flex-shrink-0"
style={{ color: "var(--danger)" }}
>
×
</button>
</div>
))}
<button
type="button"
onClick={addContact}
className="mt-2 px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
+ Add Contact
</button>
</SectionCard>
{/* Notes */}
<SectionCard title="Notes">
{form.notes.length > 0 && (
<div className="mb-4 space-y-2">
{form.notes.map((note, i) => (
<div
key={i}
className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{editingNoteIdx === i ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 56 }}
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveEditNote(i);
if (e.key === "Escape") setEditingNoteIdx(null);
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => saveEditNote(i)}
disabled={!editingNoteText.trim()}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: editingNoteText.trim() ? 1 : 0.5 }}
>Save</button>
<button
type="button"
onClick={() => setEditingNoteIdx(null)}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}
>Cancel</button>
</div>
</div>
) : (
<>
<p>{note.text}</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => startEditNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>Edit</button>
<button
type="button"
onClick={() => removeNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}
>Remove</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
<div className="flex gap-2">
<textarea
className={inputClass + " flex-1"}
style={{ ...inputStyle, resize: "vertical", minHeight: 64 }}
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
placeholder="Add a note..."
/>
<button
type="button"
onClick={addNote}
disabled={!newNoteText.trim()}
className="px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 self-start"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: newNoteText.trim() ? 1 : 0.5 }}
>
Add
</button>
</div>
</SectionCard>
{/* Delete */}
{isEdit && canEdit && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-2" style={{ color: "var(--danger)" }}>Danger Zone</h2>
{!showDeleteConfirm ? (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger)" }}
>
Delete Customer
</button>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
Are you sure? This cannot be undone.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
function primaryContact(customer, type) {
const contacts = customer.contacts || [];
const primary = contacts.find((c) => c.type === type && c.primary);
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
}
export default function CustomerList() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fetchCustomers = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (tagFilter) params.set("tag", tagFilter);
const qs = params.toString();
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
setCustomers(data.customers);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCustomers();
}, [search, tagFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Customers</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/customers/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Customer
</button>
)}
</div>
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name, location, email, phone, tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
<input
type="text"
placeholder="Filter by tag..."
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="w-40 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : customers.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No customers found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
</tr>
</thead>
<tbody>
{customers.map((c, index) => {
const loc = c.location || {};
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
return (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "email") || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "phone") || "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(c.tags || []).slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
{tag}
</span>
))}
{(c.tags || []).length > 3 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
+{c.tags.length - 3}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { default as CustomerList } from "./CustomerList";
export { default as CustomerForm } from "./CustomerForm";
export { default as CustomerDetail } from "./CustomerDetail";

View File

@@ -0,0 +1,466 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
import MailViewModal from "../components/MailViewModal";
import ComposeEmailModal from "../components/ComposeEmailModal";
import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons";
// Display labels for transport types - always lowercase
const TYPE_LABELS = {
email: "e-mail",
whatsapp: "whatsapp",
call: "phonecall",
sms: "sms",
note: "note",
in_person: "in person",
};
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
const DIRECTIONS = ["inbound", "outbound", "internal"];
const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
function formatCommDateTime(value) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return `${COMM_DATE_FMT.format(d)} · ${COMM_TIME_FMT.format(d).toLowerCase()}`;
}
const selectStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
fontSize: 13,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid",
cursor: "pointer",
};
// Customer search mini modal (replaces the giant dropdown)
function CustomerPickerModal({ open, onClose, customers, value, onChange }) {
const [q, setQ] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 60); }
}, [open]);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const lower = q.trim().toLowerCase();
const filtered = customers.filter((c) =>
!lower ||
(c.name || "").toLowerCase().includes(lower) ||
(c.surname || "").toLowerCase().includes(lower) ||
(c.organization || "").toLowerCase().includes(lower) ||
(c.contacts || []).some((ct) => (ct.value || "").toLowerCase().includes(lower))
);
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 500, backgroundColor: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 10, width: 380, maxHeight: 460, display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.35)", overflow: "hidden" }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--border-secondary)" }}>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search customer..."
style={{ width: "100%", padding: "7px 10px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-input)", color: "var(--text-primary)", outline: "none" }}
/>
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
{/* All customers option */}
<div
onClick={() => { onChange(""); onClose(); }}
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === "" ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === "" ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent", fontWeight: value === "" ? 600 : 400 }}
onMouseEnter={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "transparent"; }}
>
All customers
</div>
{filtered.map((c) => (
<div
key={c.id}
onClick={() => { onChange(c.id); onClose(); }}
style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === c.id ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent" }}
onMouseEnter={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent"; }}
>
<div style={{ fontWeight: 500 }}>{c.name}{c.surname ? ` ${c.surname}` : ""}</div>
{c.organization && <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 1 }}>{c.organization}</div>}
</div>
))}
{filtered.length === 0 && q && (
<div style={{ padding: "16px 14px", textAlign: "center", fontSize: 13, color: "var(--text-muted)" }}>No customers found</div>
)}
</div>
</div>
</div>
);
}
export default function CommsPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [dirFilter, setDirFilter] = useState("");
const [custFilter, setCustFilter] = useState("");
const [expandedId, setExpandedId] = useState(null); // only 1 at a time
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [custPickerOpen, setCustPickerOpen] = useState(false);
// Modals
const [viewEntry, setViewEntry] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [composeTo, setComposeTo] = useState("");
const [composeFromAccount, setComposeFromAccount] = useState("");
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ limit: 200 });
if (typeFilter) params.set("type", typeFilter);
if (dirFilter) params.set("direction", dirFilter);
const [commsData, custsData] = await Promise.all([
api.get(`/crm/comms/all?${params}`),
api.get("/crm/customers"),
]);
setEntries(commsData.entries || []);
const map = {};
for (const c of custsData.customers || []) map[c.id] = c;
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [typeFilter, dirFilter]);
useEffect(() => { loadAll(); }, [loadAll]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
// Toggle expand — only one at a time
const toggleExpand = (id) =>
setExpandedId((prev) => (prev === id ? null : id));
const openReply = (entry) => {
const toAddr = entry.direction === "inbound"
? (entry.from_addr || "")
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : "");
setViewEntry(null);
setComposeTo(toAddr);
setComposeOpen(true);
};
const filtered = custFilter
? entries.filter((e) => e.customer_id === custFilter)
: entries;
const sortedFiltered = [...filtered].sort((a, b) => {
const ta = Date.parse(a?.occurred_at || a?.created_at || "") || 0;
const tb = Date.parse(b?.occurred_at || b?.created_at || "") || 0;
if (tb !== ta) return tb - ta;
return String(b?.id || "").localeCompare(String(a?.id || ""));
});
const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
);
const selectedCustomerLabel = custFilter && customers[custFilter]
? customers[custFilter].name + (customers[custFilter].organization ? `${customers[custFilter].organization}` : "")
: "All customers";
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Activity Log</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
All customer communications across all channels
</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error
? syncResult.error
: `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={syncEmails}
disabled={syncing || loading}
title="Connect to mail server and download new emails into the log"
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Emails"}
</button>
<button
onClick={loadAll}
disabled={loading}
title="Reload from local database"
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABELS[t] || t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
{/* Customer picker button */}
<button
type="button"
onClick={() => setCustPickerOpen(true)}
style={{
...selectStyle,
minWidth: 180,
textAlign: "left",
color: custFilter ? "var(--accent)" : "var(--text-primary)",
fontWeight: custFilter ? 600 : 400,
}}
>
{selectedCustomerLabel}
</button>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : sortedFiltered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No communications found.
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
</div>
<div style={{ position: "relative" }}>
{/* Connector line */}
<div style={{
position: "absolute", left: 19, top: 12, bottom: 12,
width: 2, backgroundColor: "var(--border-secondary)", zIndex: 0,
}} />
<div className="space-y-2">
{sortedFiltered.map((entry) => {
const customer = customers[entry.customer_id];
const isExpanded = expandedId === entry.id;
const isEmail = entry.type === "email";
return (
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}>
{/* Type icon marker */}
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
<CommTypeIconBadge type={entry.type} />
</div>
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
>
{/* Entry header */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
<CommDirectionIcon direction={entry.direction} />
{customer ? (
<Link
to={`/crm/customers/${entry.customer_id}`}
className="text-xs font-medium hover:underline"
style={{ color: "var(--accent)" }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
{customer.organization ? ` · ${customer.organization}` : ""}
</Link>
) : (
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>
{entry.from_addr || entry.customer_id || "—"}
</span>
)}
{entry.subject && (
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 280 }}>
{entry.subject}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{/* Full View button (for email entries) */}
{isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setViewEntry(entry); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 flex-shrink-0"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-primary)" }}
>
Full View
</button>
)}
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{formatCommDateTime(entry.occurred_at)}
</span>
{entry.body && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
</span>
)}
</div>
</div>
{/* Body */}
{entry.body && (
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
<div style={{ borderTop: "1px solid var(--border-secondary)", marginLeft: 0, marginRight: 0 }} />
<p
className="text-sm mt-2"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2,
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
{entry.body}
</p>
</div>
)}
{/* Footer: logged_by + attachments + Quick Reply */}
{(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
{entry.logged_by && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
by {entry.logged_by}
</span>
)}
{entry.attachments?.length > 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
</span>
)}
{isExpanded && isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); openReply(entry); }}
className="ml-auto text-xs px-2 py-1 rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none" }}
>
Quick Reply
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Customer Picker Modal */}
<CustomerPickerModal
open={custPickerOpen}
onClose={() => setCustPickerOpen(false)}
customers={customerOptions}
value={custFilter}
onChange={setCustFilter}
/>
{/* Mail View Modal */}
<MailViewModal
open={!!viewEntry}
onClose={() => setViewEntry(null)}
entry={viewEntry}
customerName={viewEntry ? customers[viewEntry.customer_id]?.name : null}
onReply={(toAddr, sourceAccount) => {
setViewEntry(null);
setComposeTo(toAddr);
setComposeFromAccount(sourceAccount || "");
setComposeOpen(true);
}}
/>
{/* Compose Modal */}
<ComposeEmailModal
open={composeOpen}
onClose={() => { setComposeOpen(false); setComposeFromAccount(""); }}
defaultTo={composeTo}
defaultFromAccount={composeFromAccount}
requireFromAccount={true}
onSent={() => loadAll()}
/>
</div>
);
}

View File

@@ -0,0 +1,327 @@
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
const TYPE_COLORS = {
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
whatsapp: { bg: "#dcfce7", color: "#166534" },
call: { bg: "#fef9c3", color: "#854d0e" },
sms: { bg: "#fef3c7", color: "#92400e" },
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
in_person: { bg: "#ede9fe", color: "#5b21b6" },
};
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
const DIRECTIONS = ["inbound", "outbound", "internal"];
function TypeBadge({ type }) {
const s = TYPE_COLORS[type] || TYPE_COLORS.note;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: s.bg, color: s.color }}
>
{type}
</span>
);
}
function DirectionIcon({ direction }) {
if (direction === "inbound")
return <span title="Inbound" style={{ color: "var(--success-text)" }}></span>;
if (direction === "outbound")
return <span title="Outbound" style={{ color: "var(--accent)" }}></span>;
return <span title="Internal" style={{ color: "var(--text-muted)" }}></span>;
}
export default function InboxPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [dirFilter, setDirFilter] = useState("");
const [custFilter, setCustFilter] = useState("");
const [expanded, setExpanded] = useState({});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null); // { new_count } | null
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ limit: 200 });
if (typeFilter) params.set("type", typeFilter);
if (dirFilter) params.set("direction", dirFilter);
const [commsData, custsData] = await Promise.all([
api.get(`/crm/comms/all?${params}`),
api.get("/crm/customers"),
]);
setEntries(commsData.entries || []);
// Build id→name map
const map = {};
for (const c of custsData.customers || []) {
map[c.id] = c;
}
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [typeFilter, dirFilter]);
useEffect(() => {
loadAll();
}, [loadAll]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
const toggleExpand = (id) =>
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
// Client-side customer filter
const filtered = custFilter
? entries.filter((e) => e.customer_id === custFilter)
: entries;
const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
);
const selectStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
fontSize: 13,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid",
cursor: "pointer",
};
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Inbox</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
All communications across all customers
</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={syncEmails}
disabled={syncing || loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Emails"}
</button>
<button
onClick={loadAll}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", border: "1px solid", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
<select value={custFilter} onChange={(e) => setCustFilter(e.target.value)} style={selectStyle}>
<option value="">All customers</option>
{customerOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.organization ? `${c.organization}` : ""}</option>
))}
</select>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No communications found.
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{filtered.length} entr{filtered.length !== 1 ? "ies" : "y"}
</div>
{/* Timeline */}
<div style={{ position: "relative" }}>
{/* Connector line */}
<div
style={{
position: "absolute",
left: 19,
top: 12,
bottom: 12,
width: 2,
backgroundColor: "var(--border-secondary)",
zIndex: 0,
}}
/>
<div className="space-y-2">
{filtered.map((entry) => {
const customer = customers[entry.customer_id];
const isExpanded = !!expanded[entry.id];
const typeStyle = TYPE_COLORS[entry.type] || TYPE_COLORS.note;
return (
<div
key={entry.id}
style={{ position: "relative", paddingLeft: 44 }}
>
{/* Dot */}
<div
style={{
position: "absolute",
left: 12,
top: 14,
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: typeStyle.bg,
border: `2px solid ${typeStyle.color}`,
zIndex: 1,
}}
/>
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
>
{/* Entry header */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
<TypeBadge type={entry.type} />
<DirectionIcon direction={entry.direction} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{entry.direction}</span>
{customer ? (
<Link
to={`/crm/customers/${entry.customer_id}`}
className="text-xs font-medium hover:underline"
style={{ color: "var(--accent)" }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
{customer.organization ? ` · ${customer.organization}` : ""}
</Link>
) : (
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{entry.customer_id}</span>
)}
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}>
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
</span>
{entry.body && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
</span>
)}
</div>
{/* Subject / body preview */}
{(entry.subject || entry.body) && (
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border-secondary)" }}>
{entry.subject && (
<p className="text-sm font-medium mt-2" style={{ color: "var(--text-heading)" }}>
{entry.subject}
</p>
)}
{entry.body && (
<p
className="text-sm mt-1"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2,
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
{entry.body}
</p>
)}
</div>
)}
{/* Footer */}
{(entry.logged_by || (entry.attachments && entry.attachments.length > 0)) && (
<div className="px-4 pb-2 flex items-center gap-3">
{entry.logged_by && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
by {entry.logged_by}
</span>
)}
{entry.attachments && entry.attachments.length > 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,838 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
import ComposeEmailModal from "../components/ComposeEmailModal";
import MailViewModal from "../components/MailViewModal";
const TABS = ["inbound", "outbound"];
const CLIENT_FILTER_TABS = ["all", "clients"];
const MAILBOX_TABS = ["sales", "support", "both"];
const READ_FILTER_TABS = ["all", "unread", "read", "important"];
const FILTER_COLORS = {
inbound: "var(--mail-filter-green)",
outbound: "var(--mail-filter-blue)",
all_messages: "var(--mail-filter-yellow)",
clients_only: "var(--mail-filter-green)",
sales: "var(--mail-filter-orange)",
support: "var(--mail-filter-red)",
mailbox_all: "var(--mail-filter-green)",
read_all: "var(--mail-filter-yellow)",
unread: "var(--mail-filter-green)",
read: "var(--mail-filter-blue)",
important: "var(--mail-filter-red)",
};
// Fixed pixel width of the identity (sender/recipient) column
const ID_COL_W = 210;
const DEFAULT_POLL_INTERVAL = 30; // seconds
function getPollInterval() {
const stored = parseInt(localStorage.getItem("mail_poll_interval"), 10);
if (!isNaN(stored) && stored >= 15 && stored <= 300) return stored;
return DEFAULT_POLL_INTERVAL;
}
// Relative time helper
function relativeTime(dateStr) {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
if (diff < 0) return "just now";
const secs = Math.floor(diff / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function segmentedButtonStyle(active, color, borderRight = "none", inactiveTextColor = "var(--text-white)") {
return {
backgroundColor: active ? color : "var(--bg-card)",
color: active ? "var(--text-white)" : inactiveTextColor,
borderRight,
fontWeight: active ? 600 : 500,
};
}
function BookmarkButton({ important, onClick }) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }}
title={important ? "Remove bookmark" : "Bookmark"}
style={{
background: "none",
border: "none",
padding: "0 2px",
cursor: "pointer",
lineHeight: 1,
color: important ? "#f59e0b" : "var(--border-primary)",
opacity: important ? 1 : 0.35,
flexShrink: 0,
transition: "opacity 0.15s, color 0.15s",
display: "flex",
alignItems: "center",
}}
className="row-star"
>
<svg width="13" height="16" viewBox="0 0 13 16" fill={important ? "#f59e0b" : "currentColor"} xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1C1.22386 1 1 1.22386 1 1.5V14.5C1 14.7652 1.14583 14.9627 1.35217 15.0432C1.55851 15.1237 1.78462 15.0693 1.93301 14.9045L6.5 9.81L11.067 14.9045C11.2154 15.0693 11.4415 15.1237 11.6478 15.0432C11.8542 14.9627 12 14.7652 12 14.5V1.5C12 1.22386 11.7761 1 11.5 1H1.5Z"/>
</svg>
</button>
);
}
// Settings / Signature Modal
function SettingsModal({ open, onClose }) {
const [signature, setSignature] = useState(() => localStorage.getItem("mail_signature") || "");
const [saved, setSaved] = useState(false);
const [pollInterval, setPollInterval] = useState(() => getPollInterval());
const editorRef = useRef(null);
const quillRef = useRef(null);
const [quillLoaded, setQuillLoaded] = useState(!!window.Quill);
// ESC to close
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Load Quill (reuse global loader pattern)
useEffect(() => {
if (!open) return;
if (window.Quill) { setQuillLoaded(true); return; }
if (!document.getElementById("__quill_js__")) {
const link = document.createElement("link");
link.id = "__quill_css__";
link.rel = "stylesheet";
link.href = "https://cdn.quilljs.com/1.3.7/quill.snow.css";
document.head.appendChild(link);
const script = document.createElement("script");
script.id = "__quill_js__";
script.src = "https://cdn.quilljs.com/1.3.7/quill.min.js";
script.onload = () => setQuillLoaded(true);
document.head.appendChild(script);
}
}, [open]);
useEffect(() => {
if (!open || !quillLoaded || !editorRef.current || quillRef.current) return;
const q = new window.Quill(editorRef.current, {
theme: "snow",
placeholder: "Your signature...",
modules: {
toolbar: [
["bold", "italic", "underline"],
[{ color: [] }],
["link"],
["clean"],
],
},
});
// Load existing signature HTML
const saved = localStorage.getItem("mail_signature") || "";
if (saved) q.clipboard.dangerouslyPasteHTML(saved);
quillRef.current = q;
return () => { quillRef.current = null; };
}, [open, quillLoaded]);
const handleSave = () => {
const html = quillRef.current ? quillRef.current.root.innerHTML : signature;
localStorage.setItem("mail_signature", html);
const interval = Math.min(300, Math.max(15, parseInt(pollInterval, 10) || DEFAULT_POLL_INTERVAL));
localStorage.setItem("mail_poll_interval", String(interval));
setSaved(true);
setTimeout(() => { setSaved(false); onClose(); }, 800);
};
if (!open) return null;
return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1100, backgroundColor: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 60 }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 12, width: "min(700px, 94vw)", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 20px 60px rgba(0,0,0,0.4)", maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid var(--border-primary)", flexShrink: 0 }}>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Mail Settings</h2>
<button type="button" onClick={onClose} style={{ color: "var(--text-muted)", background: "none", border: "none", fontSize: 22, cursor: "pointer", lineHeight: 1 }}>×</button>
</div>
{/* Polling Rate */}
<div className="px-5 pt-4 pb-3 flex-shrink-0" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-secondary)" }}>Auto-Check Interval</p>
<div className="flex items-center gap-3">
<input
type="number"
min={15}
max={300}
value={pollInterval}
onChange={(e) => setPollInterval(e.target.value)}
className="px-3 py-1.5 text-sm rounded-md border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", width: 90 }}
/>
<span className="text-sm" style={{ color: "var(--text-muted)" }}>seconds <span style={{ fontSize: 11 }}>(min 15, max 300)</span></span>
</div>
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)" }}>
How often to check if new emails are available on the server. A banner will appear if new mail is found.
</p>
</div>
<div className="px-5 pt-4 pb-2 flex-shrink-0">
<p className="text-xs font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-secondary)" }}>Email Signature</p>
</div>
<div className="flex-1 overflow-auto quill-sig-wrapper" style={{ minHeight: 200, paddingBottom: 0 }}>
{quillLoaded ? (
<div ref={editorRef} style={{ minHeight: 160, backgroundColor: "var(--bg-input)", color: "var(--text-primary)" }} />
) : (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-muted)" }}>Loading editor...</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-5 py-4" style={{ borderTop: "1px solid var(--border-primary)", flexShrink: 0 }}>
{saved && <span className="text-xs" style={{ color: "var(--success-text)" }}>Saved!</span>}
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80" style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card)" }}>
Cancel
</button>
<button type="button" onClick={handleSave} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>
Save Settings
</button>
</div>
</div>
<style>{`
.quill-sig-wrapper .ql-toolbar { background: var(--bg-card-hover); border-color: var(--border-primary) !important; border-top: none !important; }
.quill-sig-wrapper .ql-toolbar button, .quill-sig-wrapper .ql-toolbar .ql-picker-label { color: var(--text-secondary) !important; }
.quill-sig-wrapper .ql-toolbar .ql-stroke { stroke: var(--text-secondary) !important; }
.quill-sig-wrapper .ql-toolbar button:hover .ql-stroke, .quill-sig-wrapper .ql-toolbar button.ql-active .ql-stroke { stroke: var(--accent) !important; }
.quill-sig-wrapper .ql-container { border-color: var(--border-secondary) !important; }
.quill-sig-wrapper .ql-editor { color: var(--text-primary) !important; background: var(--bg-input) !important; min-height: 160px; }
.quill-sig-wrapper .ql-editor.ql-blank::before { color: var(--text-muted) !important; font-style: normal !important; }
`}</style>
</div>
);
}
export default function MailPage() {
const [entries, setEntries] = useState([]);
const [customers, setCustomers] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState("inbound");
const [clientFilter, setClientFilter] = useState("all");
const [readFilter, setReadFilter] = useState("all");
const [search, setSearch] = useState("");
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
// New-mail banner
const [newMailCount, setNewMailCount] = useState(0);
const [bannerDismissed, setBannerDismissed] = useState(false);
// Polling
const pollIntervalRef = useRef(null);
// Multi-select
const [selected, setSelected] = useState(new Set());
const [hoveredId, setHoveredId] = useState(null);
const [deleting, setDeleting] = useState(false);
// Modals
const [viewEntry, setViewEntry] = useState(null);
const [composeOpen, setComposeOpen] = useState(false);
const [composeTo, setComposeTo] = useState("");
const [composeFromAccount, setComposeFromAccount] = useState("");
const [mailboxFilter, setMailboxFilter] = useState("both");
const [settingsOpen, setSettingsOpen] = useState(false);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const [mailData, custsData] = await Promise.all([
api.get(`/crm/comms/email/all?limit=500&mailbox=${encodeURIComponent(mailboxFilter)}`),
api.get("/crm/customers"),
]);
setEntries(mailData.entries || []);
const map = {};
for (const c of custsData.customers || []) map[c.id] = c;
setCustomers(map);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [mailboxFilter]);
useEffect(() => { loadAll(); }, [loadAll]);
// Clear selection when tab changes
useEffect(() => { setSelected(new Set()); setReadFilter("all"); }, [activeTab]);
// Auto-poll: check for new emails on server
const startPolling = useCallback(() => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
const intervalMs = getPollInterval() * 1000;
pollIntervalRef.current = setInterval(async () => {
try {
const data = await api.get("/crm/comms/email/check");
if (data.new_count > 0) {
setNewMailCount(data.new_count);
setBannerDismissed(false);
}
} catch {
// silently ignore poll errors
}
}, intervalMs);
}, []);
useEffect(() => {
startPolling();
return () => { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); };
}, [startPolling]);
const syncEmails = async () => {
setSyncing(true);
setSyncResult(null);
setNewMailCount(0);
setBannerDismissed(false);
try {
const data = await api.post("/crm/comms/email/sync", {});
setSyncResult(data);
await loadAll();
} catch (err) {
setSyncResult({ error: err.message });
} finally {
setSyncing(false);
}
};
const openReply = (toAddr, sourceAccount = "") => {
setViewEntry(null);
setComposeTo(toAddr || "");
setComposeFromAccount(sourceAccount || "");
setComposeOpen(true);
};
const toggleSelect = (id) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const deleteSelected = async () => {
if (selected.size === 0) return;
setDeleting(true);
try {
await api.post("/crm/comms/bulk-delete", { ids: [...selected] });
setSelected(new Set());
await loadAll();
} catch (err) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const toggleImportant = async (entry) => {
const newVal = !entry.is_important;
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_important: newVal } : e)
);
try {
await api.patch(`/crm/comms/${entry.id}/important`, { important: newVal });
} catch {
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_important: !newVal } : e)
);
}
};
const openEntry = async (entry) => {
setViewEntry(entry);
// Mark inbound as read if not already
if (entry.direction === "inbound" && !entry.is_read) {
setEntries((prev) =>
prev.map((e) => e.id === entry.id ? { ...e, is_read: true } : e)
);
try {
await api.patch(`/crm/comms/${entry.id}/read`, { read: true });
} catch {
// non-critical — don't revert
}
}
};
const q = search.trim().toLowerCase();
const customerEmailMap = useMemo(() => {
const map = {};
Object.values(customers).forEach((c) => {
(c.contacts || []).forEach((ct) => {
if (ct?.type === "email" && ct?.value) map[String(ct.value).toLowerCase()] = c.id;
});
});
return map;
}, [customers]);
const resolveCustomerId = useCallback((entry) => {
if (entry.customer_id && customers[entry.customer_id]) return entry.customer_id;
const candidates = [];
if (entry.from_addr) candidates.push(String(entry.from_addr).toLowerCase());
const toList = Array.isArray(entry.to_addrs) ? entry.to_addrs : (entry.to_addrs ? [entry.to_addrs] : []);
toList.forEach((addr) => candidates.push(String(addr).toLowerCase()));
for (const addr of candidates) {
if (customerEmailMap[addr]) return customerEmailMap[addr];
}
return null;
}, [customerEmailMap, customers]);
const tabEntries = entries.filter((e) => e.direction === activeTab);
const clientFiltered = clientFilter === "clients"
? tabEntries.filter((e) => !!resolveCustomerId(e))
: tabEntries;
const readFiltered = readFilter === "unread"
? clientFiltered.filter((e) => !e.is_read)
: readFilter === "read"
? clientFiltered.filter((e) => !!e.is_read)
: readFilter === "important"
? clientFiltered.filter((e) => !!e.is_important)
: clientFiltered;
const filtered = readFiltered.filter((e) => {
if (!q) return true;
const custId = resolveCustomerId(e);
const cust = custId ? customers[custId] : null;
return (
(e.subject || "").toLowerCase().includes(q) ||
(e.body || "").toLowerCase().includes(q) ||
(e.from_addr || "").toLowerCase().includes(q) ||
(cust?.name || "").toLowerCase().includes(q) ||
(cust?.organization || "").toLowerCase().includes(q)
);
});
const unreadInboundCount = entries.filter((e) => e.direction === "inbound" && !e.is_read).length;
const anySelected = selected.size > 0;
const showBanner = newMailCount > 0 && !bannerDismissed;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Mail</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>All synced emails</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={() => { setComposeTo(""); setComposeFromAccount(""); setComposeOpen(true); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Compose
</button>
<button
onClick={syncEmails}
disabled={syncing || loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Mail"}
</button>
<button
onClick={loadAll}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
<button
onClick={() => setSettingsOpen(true)}
title="Mail Settings"
className="px-2 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
</button>
</div>
</div>
{/* New-mail banner */}
{showBanner && (
<div
className="flex items-center justify-between px-4 py-2.5 rounded-lg mb-4 text-sm"
style={{ backgroundColor: "color-mix(in srgb, var(--accent) 12%, var(--bg-card))", border: "1px solid var(--accent)", color: "var(--text-heading)" }}
>
<span>
<span style={{ fontWeight: 600 }}>{newMailCount} new email{newMailCount !== 1 ? "s" : ""}</span> available on server
</span>
<div className="flex items-center gap-2">
<button
onClick={syncEmails}
disabled={syncing}
className="px-3 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: syncing ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Now"}
</button>
<button
onClick={() => setBannerDismissed(true)}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1, padding: "0 2px" }}
>
×
</button>
</div>
</div>
)}
{/* Tabs + Filters + Search + Bulk actions */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{/* Direction Tabs */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{TABS.map((tab) => {
const count = entries.filter((e) => e.direction === tab).length;
const active = tab === activeTab;
const unread = tab === "inbound" ? unreadInboundCount : 0;
const color = tab === "inbound" ? FILTER_COLORS.inbound : FILTER_COLORS.outbound;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="px-4 py-1.5 text-sm cursor-pointer capitalize"
style={segmentedButtonStyle(
active,
color,
tab === "inbound" ? "1px solid var(--border-primary)" : "none",
color
)}
>
{tab}{" "}
<span style={{ opacity: 0.7, fontSize: 11 }}>({count})</span>
{unread > 0 && (
<span
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
backgroundColor: active ? "rgba(255,255,255,0.25)" : "var(--accent)",
color: active ? "var(--text-white)" : "#fff",
borderRadius: 10, fontSize: 10, fontWeight: 700,
minWidth: 16, height: 16, padding: "0 4px", marginLeft: 5,
}}
>
{unread}
</span>
)}
</button>
);
})}
</div>
{/* Client filter */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{[["all", "All Messages"], ["clients", "Clients Only"]].map(([val, label]) => {
const active = clientFilter === val;
const color = val === "all" ? FILTER_COLORS.all_messages : FILTER_COLORS.clients_only;
return (
<button
key={val}
onClick={() => setClientFilter(val)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, val === "all" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{MAILBOX_TABS.map((tab) => {
const active = mailboxFilter === tab;
const label = tab === "both" ? "ALL" : tab[0].toUpperCase() + tab.slice(1);
const color = tab === "sales" ? FILTER_COLORS.sales : tab === "support" ? FILTER_COLORS.support : FILTER_COLORS.mailbox_all;
return (
<button
key={tab}
onClick={() => setMailboxFilter(tab)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, tab !== "both" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
{/* Read status filter */}
<div className="flex rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-primary)" }}>
{READ_FILTER_TABS.map((val) => {
const active = readFilter === val;
const label = val === "all" ? "All" : val === "unread" ? "Unread" : val === "read" ? "Read" : "Importants";
const color = val === "all"
? FILTER_COLORS.read_all
: val === "unread"
? FILTER_COLORS.unread
: val === "read"
? FILTER_COLORS.read
: FILTER_COLORS.important;
return (
<button
key={val}
onClick={() => setReadFilter(val)}
className="px-4 py-1.5 text-sm cursor-pointer"
style={segmentedButtonStyle(active, color, val !== "important" ? "1px solid var(--border-primary)" : "none")}
>
{label}
</button>
);
})}
</div>
{/* Search */}
<div style={{ flex: "1 1 320px", minWidth: 220, maxWidth: 800, display: "flex", gap: 8 }}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search subject, sender, customer..."
className="px-3 text-sm rounded-md border"
style={{
height: 34,
width: "100%",
minWidth: 220,
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
className="px-3 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ height: 34, color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear
</button>
)}
</div>
{/* Bulk delete */}
{anySelected && (
<button
onClick={deleteSelected}
disabled={deleting}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80 ml-auto"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: deleting ? 0.6 : 1 }}
>
{deleting ? "Deleting..." : `Delete ${selected.size} selected`}
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
{tabEntries.length === 0 ? `No ${activeTab} emails yet.` : "No emails match your search."}
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{filtered.length} email{filtered.length !== 1 ? "s" : ""}
{anySelected && <span style={{ color: "var(--accent)", marginLeft: 8 }}>{selected.size} selected</span>}
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
{filtered.map((entry, idx) => {
const resolvedCustomerId = resolveCustomerId(entry);
const customer = resolvedCustomerId ? customers[resolvedCustomerId] : null;
const addrLine = activeTab === "inbound"
? (entry.from_addr || "")
: (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : (entry.to_addrs || ""));
const isSelected = selected.has(entry.id);
const isKnownCustomer = !!customer;
const isUnread = entry.direction === "inbound" && !entry.is_read;
return (
<div
key={entry.id}
className={idx > 0 ? "border-t" : ""}
style={{ borderColor: "var(--border-secondary)" }}
onMouseEnter={() => setHoveredId(entry.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div
className="flex items-center gap-3 px-3 py-3 cursor-pointer"
style={{
backgroundColor: isSelected
? "color-mix(in srgb, var(--btn-primary) 10%, var(--bg-card))"
: isUnread
? "color-mix(in srgb, var(--accent) 5%, var(--bg-card))"
: "var(--bg-card)",
transition: "background-color 0.1s",
}}
onClick={() => {
if (anySelected) toggleSelect(entry.id);
else openEntry(entry);
}}
>
{/* Unread dot */}
<div style={{ width: 8, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{isUnread && (
<span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: "var(--accent)", display: "block", flexShrink: 0 }} />
)}
</div>
{/* Bookmark */}
<BookmarkButton important={entry.is_important} onClick={() => toggleImportant(entry)} />
{/* Identity column */}
<div style={{ width: ID_COL_W, flexShrink: 0, minWidth: 0, overflow: "hidden" }}>
{isKnownCustomer ? (
<>
<Link
to={`/crm/customers/${resolvedCustomerId}`}
className="hover:underline block text-xs leading-tight"
style={{ color: "var(--accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
</Link>
{customer.organization && (
<span
className="block text-xs leading-tight"
style={{ color: "var(--accent)", opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{customer.organization}
</span>
)}
</>
) : (
<span
className="block text-xs leading-tight"
style={{ color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isUnread ? 700 : 500 }}
>
{addrLine}
</span>
)}
</div>
{/* Subject + preview */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className="text-sm"
style={{ color: "var(--text-heading)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block", fontWeight: isUnread ? 700 : 400 }}
>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>(no subject)</span>}
</span>
{Array.isArray(entry.attachments) && entry.attachments.length > 0 && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
📎 {entry.attachments.length}
</span>
)}
</div>
{entry.body && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{entry.body}
</p>
)}
</div>
{/* Date + checkbox + chevron */}
<div className="flex-shrink-0 text-right flex items-center gap-2" style={{ minWidth: 80 }}>
<span
className="text-xs"
title={entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
style={{ color: "var(--text-muted)", cursor: "default", fontWeight: isUnread ? 600 : 400 }}
>
{relativeTime(entry.occurred_at)}
</span>
<div style={{ width: 20, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{(anySelected || hoveredId === entry.id) ? (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(entry.id)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer", accentColor: "var(--btn-primary)", width: 14, height: 14 }}
/>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Hover CSS for star visibility */}
<style>{`
.row-star { opacity: 0.35; transition: opacity 0.15s, color 0.15s; }
div:hover > div > .row-star { opacity: 0.6; }
.row-star[title="Mark as normal"] { opacity: 1 !important; color: #f59e0b !important; }
`}</style>
<MailViewModal
open={!!viewEntry}
onClose={() => setViewEntry(null)}
entry={viewEntry}
customer={viewEntry ? customers[resolveCustomerId(viewEntry)] : null}
onReply={openReply}
/>
<ComposeEmailModal
open={composeOpen}
onClose={() => setComposeOpen(false)}
defaultTo={composeTo}
defaultFromAccount={composeFromAccount}
requireFromAccount={true}
onSent={() => loadAll()}
/>
<SettingsModal
open={settingsOpen}
onClose={() => {
setSettingsOpen(false);
startPolling(); // restart poll with potentially new interval
}}
/>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
in_production: { bg: "#fff7ed", color: "#9a3412" },
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const PAYMENT_COLORS = {
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function ReadField({ label, value }) {
return (
<div>
<div style={labelStyle}>{label}</div>
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
{value || <span style={{ color: "var(--text-muted)" }}></span>}
</div>
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="ui-section-card mb-4">
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
{children}
</div>
);
}
export default function OrderDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [order, setOrder] = useState(null);
const [customer, setCustomer] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
api.get(`/crm/orders/${id}`)
.then((data) => {
setOrder(data);
if (data.customer_id) {
api.get(`/crm/customers/${data.customer_id}`)
.then(setCustomer)
.catch(() => {});
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
if (error) {
return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
);
}
if (!order) return null;
const statusStyle = STATUS_COLORS[order.status] || STATUS_COLORS.draft;
const payStyle = PAYMENT_COLORS[order.payment_status] || PAYMENT_COLORS.pending;
const shipping = order.shipping || {};
const subtotal = (order.items || []).reduce((sum, item) => {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}, 0);
const discount = order.discount || {};
const discountAmount =
discount.type === "percentage"
? subtotal * ((Number(discount.value) || 0) / 100)
: Number(discount.value) || 0;
const total = Number(order.total_price || 0);
return (
<div style={{ maxWidth: 900 }}>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
{order.order_number}
</h1>
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
>
{(order.status || "draft").replace("_", " ")}
</span>
</div>
{customer ? (
<button
onClick={() => navigate(`/crm/customers/${customer.id}`)}
className="text-sm hover:underline"
style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
{customer.organization ? `${customer.name} / ${customer.organization}` : customer.name}
</button>
) : (
<span className="text-sm" style={{ color: "var(--text-muted)" }}>{order.customer_id}</span>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
Created {order.created_at ? new Date(order.created_at).toLocaleDateString() : "—"}
{order.updated_at && order.updated_at !== order.created_at && (
<span> · Updated {new Date(order.updated_at).toLocaleDateString()}</span>
)}
</p>
</div>
<div className="flex gap-2">
{customer && (
<button
onClick={() => navigate(`/crm/customers/${customer.id}`)}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Back to Customer
</button>
)}
{canEdit && (
<button
onClick={() => navigate(`/crm/orders/${id}/edit`)}
className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Edit
</button>
)}
</div>
</div>
{/* Items */}
<SectionCard title="Items">
{(order.items || []).length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No items.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="pb-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Item</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Qty</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Unit Price</th>
<th className="pb-2 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Line Total</th>
</tr>
</thead>
<tbody>
{order.items.map((item, idx) => {
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const label =
item.type === "product"
? item.product_name || item.product_id || "Product"
: item.type === "console_device"
? `${item.device_id || ""}${item.label ? ` (${item.label})` : ""}`
: item.description || "—";
return (
<tr
key={idx}
style={{ borderBottom: idx < order.items.length - 1 ? "1px solid var(--border-secondary)" : "none" }}
>
<td className="py-2 pr-4">
<span style={{ color: "var(--text-primary)" }}>{label}</span>
<span className="ml-2 text-xs px-1.5 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-muted)" }}>
{item.type.replace("_", " ")}
</span>
{Array.isArray(item.serial_numbers) && item.serial_numbers.length > 0 && (
<div className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
SN: {item.serial_numbers.join(", ")}
</div>
)}
</td>
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>{item.quantity}</td>
<td className="py-2 text-right" style={{ color: "var(--text-primary)" }}>
{order.currency} {Number(item.unit_price || 0).toFixed(2)}
</td>
<td className="py-2 text-right font-medium" style={{ color: "var(--text-heading)" }}>
{order.currency} {lineTotal.toFixed(2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SectionCard>
{/* Pricing Summary */}
<SectionCard title="Pricing">
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxWidth: 300, marginLeft: "auto" }}>
<div className="flex justify-between text-sm">
<span style={{ color: "var(--text-secondary)" }}>Subtotal</span>
<span style={{ color: "var(--text-primary)" }}>{order.currency} {subtotal.toFixed(2)}</span>
</div>
{discountAmount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--text-secondary)" }}>
Discount
{discount.type === "percentage" && ` (${discount.value}%)`}
{discount.reason && `${discount.reason}`}
</span>
<span style={{ color: "var(--danger-text)" }}>{order.currency} {discountAmount.toFixed(2)}</span>
</div>
)}
<div
className="flex justify-between text-sm font-semibold pt-2"
style={{ borderTop: "1px solid var(--border-primary)" }}
>
<span style={{ color: "var(--text-heading)" }}>Total</span>
<span style={{ color: "var(--text-heading)" }}>{order.currency} {total.toFixed(2)}</span>
</div>
</div>
</SectionCard>
{/* Payment */}
<SectionCard title="Payment">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<div style={labelStyle}>Payment Status</div>
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
>
{order.payment_status || "pending"}
</span>
</div>
<div>
<div style={labelStyle}>Invoice</div>
{order.invoice_path ? (
<span className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>
{order.invoice_path}
</span>
) : (
<span style={{ color: "var(--text-muted)", fontSize: 14 }}></span>
)}
</div>
</div>
</SectionCard>
{/* Shipping */}
<SectionCard title="Shipping">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<ReadField label="Method" value={shipping.method} />
<ReadField label="Carrier" value={shipping.carrier} />
<ReadField label="Tracking Number" value={shipping.tracking_number} />
<ReadField label="Destination" value={shipping.destination} />
<ReadField
label="Shipped At"
value={shipping.shipped_at ? new Date(shipping.shipped_at).toLocaleDateString() : null}
/>
<ReadField
label="Delivered At"
value={shipping.delivered_at ? new Date(shipping.delivered_at).toLocaleDateString() : null}
/>
</div>
</SectionCard>
{/* Notes */}
{order.notes && (
<SectionCard title="Notes">
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-primary)" }}>{order.notes}</p>
</SectionCard>
)}
</div>
);
}

View File

@@ -0,0 +1,662 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
const CURRENCIES = ["EUR", "USD", "GBP"];
const ITEM_TYPES = ["product", "console_device", "freetext"];
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="ui-section-card mb-4">
<h2 className="ui-section-card__header-title mb-4">{title}</h2>
{children}
</div>
);
}
const emptyItem = () => ({
type: "product",
product_id: "",
product_name: "",
description: "",
device_id: "",
label: "",
quantity: 1,
unit_price: 0,
serial_numbers: "",
});
export default function OrderForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [form, setForm] = useState({
customer_id: searchParams.get("customer_id") || "",
order_number: "",
status: "draft",
currency: "EUR",
items: [],
discount: { type: "percentage", value: 0, reason: "" },
payment_status: "pending",
invoice_path: "",
shipping: {
method: "",
carrier: "",
tracking_number: "",
destination: "",
shipped_at: "",
delivered_at: "",
},
notes: "",
});
const [customers, setCustomers] = useState([]);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
// Load customers and products
useEffect(() => {
api.get("/crm/customers").then((d) => setCustomers(d.customers || [])).catch(() => {});
api.get("/crm/products").then((d) => setProducts(d.products || [])).catch(() => {});
}, []);
// Load order for edit
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/orders/${id}`)
.then((data) => {
const shipping = data.shipping || {};
setForm({
customer_id: data.customer_id || "",
order_number: data.order_number || "",
status: data.status || "draft",
currency: data.currency || "EUR",
items: (data.items || []).map((item) => ({
...emptyItem(),
...item,
serial_numbers: Array.isArray(item.serial_numbers)
? item.serial_numbers.join(", ")
: item.serial_numbers || "",
})),
discount: data.discount || { type: "percentage", value: 0, reason: "" },
payment_status: data.payment_status || "pending",
invoice_path: data.invoice_path || "",
shipping: {
method: shipping.method || "",
carrier: shipping.carrier || "",
tracking_number: shipping.tracking_number || "",
destination: shipping.destination || "",
shipped_at: shipping.shipped_at ? shipping.shipped_at.slice(0, 10) : "",
delivered_at: shipping.delivered_at ? shipping.delivered_at.slice(0, 10) : "",
},
notes: data.notes || "",
});
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
// Set customer search label when customer_id loads
useEffect(() => {
if (form.customer_id && customers.length > 0) {
const c = customers.find((x) => x.id === form.customer_id);
if (c) setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
}
}, [form.customer_id, customers]);
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
const setShipping = (key, value) => setForm((f) => ({ ...f, shipping: { ...f.shipping, [key]: value } }));
const setDiscount = (key, value) => setForm((f) => ({ ...f, discount: { ...f.discount, [key]: value } }));
const addItem = () => setForm((f) => ({ ...f, items: [...f.items, emptyItem()] }));
const removeItem = (idx) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
const setItem = (idx, key, value) =>
setForm((f) => ({
...f,
items: f.items.map((item, i) => (i === idx ? { ...item, [key]: value } : item)),
}));
const onProductSelect = (idx, productId) => {
const product = products.find((p) => p.id === productId);
setForm((f) => ({
...f,
items: f.items.map((item, i) =>
i === idx
? { ...item, product_id: productId, product_name: product?.name || "", unit_price: product?.price || 0 }
: item
),
}));
};
// Computed pricing
const subtotal = form.items.reduce((sum, item) => {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}, 0);
const discountAmount =
form.discount.type === "percentage"
? subtotal * ((Number(form.discount.value) || 0) / 100)
: Number(form.discount.value) || 0;
const total = Math.max(0, subtotal - discountAmount);
const filteredCustomers = customerSearch
? customers.filter((c) => {
const q = customerSearch.toLowerCase();
return c.name.toLowerCase().includes(q) || (c.organization || "").toLowerCase().includes(q);
})
: customers.slice(0, 20);
const handleSave = async () => {
if (!form.customer_id) { setError("Please select a customer."); return; }
setSaving(true);
setError("");
try {
const payload = {
customer_id: form.customer_id,
order_number: form.order_number || undefined,
status: form.status,
currency: form.currency,
items: form.items.map((item) => ({
type: item.type,
product_id: item.product_id || null,
product_name: item.product_name || null,
description: item.description || null,
device_id: item.device_id || null,
label: item.label || null,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
serial_numbers: item.serial_numbers
? item.serial_numbers.split(",").map((s) => s.trim()).filter(Boolean)
: [],
})),
subtotal,
discount: {
type: form.discount.type,
value: Number(form.discount.value) || 0,
reason: form.discount.reason || "",
},
total_price: total,
payment_status: form.payment_status,
invoice_path: form.invoice_path || "",
shipping: {
method: form.shipping.method || "",
carrier: form.shipping.carrier || "",
tracking_number: form.shipping.tracking_number || "",
destination: form.shipping.destination || "",
shipped_at: form.shipping.shipped_at || null,
delivered_at: form.shipping.delivered_at || null,
},
notes: form.notes || "",
};
if (isEdit) {
await api.put(`/crm/orders/${id}`, payload);
navigate(`/crm/orders/${id}`);
} else {
const result = await api.post("/crm/orders", payload);
navigate(`/crm/orders/${result.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!window.confirm("Delete this order? This cannot be undone.")) return;
try {
await api.delete(`/crm/orders/${id}`);
navigate("/crm/orders");
} catch (err) {
setError(err.message);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
if (!canEdit) {
return <div className="text-sm p-3" style={{ color: "var(--danger-text)" }}>No permission to edit orders.</div>;
}
return (
<div style={{ maxWidth: 900 }}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Order" : "New Order"}
</h1>
<button
onClick={() => navigate(isEdit ? `/crm/orders/${id}` : "/crm/orders")}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* 1. Customer */}
<SectionCard title="Customer">
<div style={{ position: "relative" }}>
<label style={labelStyle}>Customer *</label>
<input
className={inputClass}
style={inputStyle}
placeholder="Search by name or organization..."
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
if (!e.target.value) setField("customer_id", "");
}}
onFocus={() => setShowCustomerDropdown(true)}
onBlur={() => setTimeout(() => setShowCustomerDropdown(false), 150)}
/>
{showCustomerDropdown && filteredCustomers.length > 0 && (
<div
className="absolute z-10 w-full mt-1 rounded-md border shadow-lg"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: 200, overflowY: "auto" }}
>
{filteredCustomers.map((c) => (
<div
key={c.id}
className="px-3 py-2 text-sm cursor-pointer hover:opacity-80"
style={{ color: "var(--text-primary)", borderBottom: "1px solid var(--border-secondary)" }}
onMouseDown={() => {
setField("customer_id", c.id);
setCustomerSearch(c.organization ? `${c.name} / ${c.organization}` : c.name);
setShowCustomerDropdown(false);
}}
>
<span style={{ color: "var(--text-heading)" }}>{c.name}</span>
{c.organization && (
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</div>
))}
</div>
)}
</div>
</SectionCard>
{/* 2. Order Info */}
<SectionCard title="Order Info">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="Order Number">
<input
className={inputClass}
style={inputStyle}
placeholder="Auto-generated if empty"
value={form.order_number}
onChange={(e) => setField("order_number", e.target.value)}
/>
</Field>
<Field label="Status">
<select className={inputClass} style={inputStyle} value={form.status}
onChange={(e) => setField("status", e.target.value)}>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option>
))}
</select>
</Field>
<Field label="Currency">
<select className={inputClass} style={inputStyle} value={form.currency}
onChange={(e) => setField("currency", e.target.value)}>
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</div>
</SectionCard>
{/* 3. Items */}
<SectionCard title="Items">
{form.items.length === 0 ? (
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No items yet.</p>
) : (
<div className="space-y-3 mb-4">
{form.items.map((item, idx) => (
<div
key={idx}
className="rounded-md border p-4"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 100px 120px auto", gap: 12, alignItems: "end" }}>
<Field label="Type">
<select
className={inputClass}
style={inputStyle}
value={item.type}
onChange={(e) => setItem(idx, "type", e.target.value)}
>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
</select>
</Field>
{item.type === "product" && (
<Field label="Product">
<select
className={inputClass}
style={inputStyle}
value={item.product_id}
onChange={(e) => onProductSelect(idx, e.target.value)}
>
<option value="">Select product...</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</Field>
)}
{item.type === "console_device" && (
<Field label="Device ID + Label">
<div style={{ display: "flex", gap: 8 }}>
<input
className={inputClass}
style={inputStyle}
placeholder="Device UID"
value={item.device_id}
onChange={(e) => setItem(idx, "device_id", e.target.value)}
/>
<input
className={inputClass}
style={inputStyle}
placeholder="Label"
value={item.label}
onChange={(e) => setItem(idx, "label", e.target.value)}
/>
</div>
</Field>
)}
{item.type === "freetext" && (
<Field label="Description">
<input
className={inputClass}
style={inputStyle}
placeholder="Description..."
value={item.description}
onChange={(e) => setItem(idx, "description", e.target.value)}
/>
</Field>
)}
<Field label="Qty">
<input
type="number"
min="1"
className={inputClass}
style={inputStyle}
value={item.quantity}
onChange={(e) => setItem(idx, "quantity", e.target.value)}
/>
</Field>
<Field label="Unit Price">
<input
type="number"
min="0"
step="0.01"
className={inputClass}
style={inputStyle}
value={item.unit_price}
onChange={(e) => setItem(idx, "unit_price", e.target.value)}
/>
</Field>
<div style={{ paddingBottom: 2 }}>
<button
type="button"
onClick={() => removeItem(idx)}
className="px-3 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger-text)", whiteSpace: "nowrap" }}
>
Remove
</button>
</div>
</div>
<div className="mt-3">
<Field label="Serial Numbers (comma-separated)">
<input
className={inputClass}
style={inputStyle}
placeholder="SN001, SN002..."
value={item.serial_numbers}
onChange={(e) => setItem(idx, "serial_numbers", e.target.value)}
/>
</Field>
</div>
</div>
))}
</div>
)}
<button
type="button"
onClick={addItem}
className="px-3 py-1.5 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
+ Add Item
</button>
</SectionCard>
{/* 4. Pricing */}
<SectionCard title="Pricing">
<div className="flex gap-8 mb-4">
<div>
<div style={labelStyle}>Subtotal</div>
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
{form.currency} {subtotal.toFixed(2)}
</div>
</div>
<div>
<div style={labelStyle}>Total</div>
<div className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
{form.currency} {total.toFixed(2)}
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "140px 160px 1fr", gap: 16 }}>
<Field label="Discount Type">
<select
className={inputClass}
style={inputStyle}
value={form.discount.type}
onChange={(e) => setDiscount("type", e.target.value)}
>
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount</option>
</select>
</Field>
<Field label={form.discount.type === "percentage" ? "Discount %" : "Discount Amount"}>
<input
type="number"
min="0"
step="0.01"
className={inputClass}
style={inputStyle}
value={form.discount.value}
onChange={(e) => setDiscount("value", e.target.value)}
/>
</Field>
<Field label="Discount Reason">
<input
className={inputClass}
style={inputStyle}
placeholder="Optional reason..."
value={form.discount.reason}
onChange={(e) => setDiscount("reason", e.target.value)}
/>
</Field>
</div>
{Number(form.discount.value) > 0 && (
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
Discount: {form.currency} {discountAmount.toFixed(2)}
</p>
)}
</SectionCard>
{/* 5. Payment */}
<SectionCard title="Payment">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Field label="Payment Status">
<select
className={inputClass}
style={inputStyle}
value={form.payment_status}
onChange={(e) => setField("payment_status", e.target.value)}
>
{PAYMENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Invoice Path (Nextcloud)">
<input
className={inputClass}
style={inputStyle}
placeholder="05_Customers/FOLDER/invoice.pdf"
value={form.invoice_path}
onChange={(e) => setField("invoice_path", e.target.value)}
/>
</Field>
</div>
</SectionCard>
{/* 6. Shipping */}
<SectionCard title="Shipping">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Method">
<input
className={inputClass}
style={inputStyle}
placeholder="e.g. courier, pickup"
value={form.shipping.method}
onChange={(e) => setShipping("method", e.target.value)}
/>
</Field>
<Field label="Carrier">
<input
className={inputClass}
style={inputStyle}
placeholder="e.g. ACS, DHL"
value={form.shipping.carrier}
onChange={(e) => setShipping("carrier", e.target.value)}
/>
</Field>
<Field label="Tracking Number">
<input
className={inputClass}
style={inputStyle}
value={form.shipping.tracking_number}
onChange={(e) => setShipping("tracking_number", e.target.value)}
/>
</Field>
<Field label="Destination" style={{ gridColumn: "1 / -1" }}>
<input
className={inputClass}
style={inputStyle}
placeholder="City, Country"
value={form.shipping.destination}
onChange={(e) => setShipping("destination", e.target.value)}
/>
</Field>
<Field label="Shipped At">
<input
type="date"
className={inputClass}
style={inputStyle}
value={form.shipping.shipped_at}
onChange={(e) => setShipping("shipped_at", e.target.value)}
/>
</Field>
<Field label="Delivered At">
<input
type="date"
className={inputClass}
style={inputStyle}
value={form.shipping.delivered_at}
onChange={(e) => setShipping("delivered_at", e.target.value)}
/>
</Field>
</div>
</SectionCard>
{/* 7. Notes */}
<SectionCard title="Notes">
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 100 }}
placeholder="Internal notes..."
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</SectionCard>
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 text-sm rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create Order"}
</button>
{isEdit && (
<button
onClick={handleDelete}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
Delete Order
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const STATUS_COLORS = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
confirmed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
in_production: { bg: "#fff7ed", color: "#9a3412" },
shipped: { bg: "#f5f3ff", color: "#6d28d9" },
delivered: { bg: "var(--success-bg)", color: "var(--success-text)" },
cancelled: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const PAYMENT_COLORS = {
pending: { bg: "#fef9c3", color: "#854d0e" },
partial: { bg: "#fff7ed", color: "#9a3412" },
paid: { bg: "var(--success-bg)", color: "var(--success-text)" },
};
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const ORDER_STATUSES = ["draft", "confirmed", "in_production", "shipped", "delivered", "cancelled"];
const PAYMENT_STATUSES = ["pending", "partial", "paid"];
export default function OrderList() {
const [orders, setOrders] = useState([]);
const [customerMap, setCustomerMap] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [paymentFilter, setPaymentFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
useEffect(() => {
api.get("/crm/customers")
.then((data) => {
const map = {};
(data.customers || []).forEach((c) => { map[c.id] = c; });
setCustomerMap(map);
})
.catch(() => {});
}, []);
const fetchOrders = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (paymentFilter) params.set("payment_status", paymentFilter);
const qs = params.toString();
const data = await api.get(`/crm/orders${qs ? `?${qs}` : ""}`);
setOrders(data.orders || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOrders();
}, [statusFilter, paymentFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Orders</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/orders/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Order
</button>
)}
</div>
<div className="flex gap-3 mb-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={inputStyle}
>
<option value="">All Statuses</option>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{s.replace("_", " ")}</option>
))}
</select>
<select
value={paymentFilter}
onChange={(e) => setPaymentFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={inputStyle}
>
<option value="">All Payment Statuses</option>
{PAYMENT_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : orders.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No orders found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Order #</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Customer</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Total</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Payment</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o, index) => {
const statusStyle = STATUS_COLORS[o.status] || STATUS_COLORS.draft;
const payStyle = PAYMENT_COLORS[o.payment_status] || PAYMENT_COLORS.pending;
const customer = customerMap[o.customer_id];
const customerName = customer
? customer.organization
? `${customer.name} / ${customer.organization}`
: customer.name
: o.customer_id || "—";
return (
<tr
key={o.id}
onClick={() => navigate(`/crm/orders/${o.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < orders.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === o.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(o.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-mono text-xs font-medium" style={{ color: "var(--text-heading)" }}>
{o.order_number}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{customerName}</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
>
{(o.status || "draft").replace("_", " ")}
</span>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{o.currency || "EUR"} {Number(o.total_price || 0).toFixed(2)}
</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: payStyle.bg, color: payStyle.color }}
>
{o.payment_status || "pending"}
</span>
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{o.created_at ? new Date(o.created_at).toLocaleDateString() : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { default as OrderList } from "./OrderList";
export { default as OrderForm } from "./OrderForm";
export { default as OrderDetail } from "./OrderDetail";

View File

@@ -0,0 +1,635 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CATEGORY_LABELS = {
controller: "Controller",
striker: "Striker",
clock: "Clock",
part: "Part",
repair_service: "Repair / Service",
};
const CATEGORIES = Object.keys(CATEGORY_LABELS);
const STATUS_OPTIONS = [
{ value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
{ value: "discontinued", label: "Discontinued", activeColor: "#ef4444", activeBg: "#450a0a", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
{ value: "planned", label: "Planned", activeColor: "#f59e0b", activeBg: "#451a03", inactiveBg: "var(--bg-input)", inactiveColor: "var(--text-secondary)" },
];
const defaultForm = {
name: "",
sku: "",
category: "controller",
description: "",
price: "",
currency: "EUR",
status: "active",
costs: {
labor_hours: "",
labor_rate: "",
items: [],
},
stock: {
on_hand: "",
reserved: "",
},
};
function numOr(v, fallback = 0) {
const n = parseFloat(v);
return isNaN(n) ? fallback : n;
}
function computeCostsTotal(costs, priceField = "price_last") {
const labor = numOr(costs.labor_hours) * numOr(costs.labor_rate);
const itemsTotal = (costs.items || []).reduce(
(sum, it) => sum + numOr(it.quantity, 1) * numOr(it[priceField] || it.price_last || it.price),
0
);
return labor + itemsTotal;
}
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
function Field({ label, children }) {
return (
<div>
<label className="ui-form-label">{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children, style }) {
return (
<div className="ui-section-card" style={style}>
{title && (
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">{title}</h2>
</div>
)}
{children}
</div>
);
}
export default function ProductForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fileInputRef = useRef(null);
const [form, setForm] = useState(defaultForm);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [photoPreview, setPhotoPreview] = useState(null); // local blob URL for preview
const [photoFile, setPhotoFile] = useState(null); // File object pending upload
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [existingPhotoUrl, setExistingPhotoUrl] = useState(null);
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/products/${id}`)
.then((data) => {
setForm({
name: data.name || "",
sku: data.sku || "",
category: data.category || "controller",
description: data.description || "",
price: data.price != null ? String(data.price) : "",
currency: data.currency || "EUR",
status: data.status || (data.active !== false ? "active" : "discontinued"),
costs: {
labor_hours: data.costs?.labor_hours != null ? String(data.costs.labor_hours) : "",
labor_rate: data.costs?.labor_rate != null ? String(data.costs.labor_rate) : "",
items: (data.costs?.items || []).map((it) => ({
name: it.name || "",
quantity: String(it.quantity ?? 1),
price_last: String(it.price_last ?? it.price ?? ""),
price_min: String(it.price_min ?? ""),
price_max: String(it.price_max ?? ""),
})),
},
stock: {
on_hand: data.stock?.on_hand != null ? String(data.stock.on_hand) : "",
reserved: data.stock?.reserved != null ? String(data.stock.reserved) : "",
},
});
if (data.photo_url) {
setExistingPhotoUrl(`/api${data.photo_url}`);
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
const setCost = (field, value) => setForm((f) => ({ ...f, costs: { ...f.costs, [field]: value } }));
const setStock = (field, value) => setForm((f) => ({ ...f, stock: { ...f.stock, [field]: value } }));
const addCostItem = () =>
setForm((f) => ({
...f,
costs: { ...f.costs, items: [...f.costs.items, { name: "", quantity: "1", price_last: "", price_min: "", price_max: "" }] },
}));
const removeCostItem = (i) =>
setForm((f) => ({
...f,
costs: { ...f.costs, items: f.costs.items.filter((_, idx) => idx !== i) },
}));
const setCostItem = (i, field, value) =>
setForm((f) => ({
...f,
costs: {
...f.costs,
items: f.costs.items.map((it, idx) => idx === i ? { ...it, [field]: value } : it),
},
}));
function handlePhotoChange(e) {
const file = e.target.files?.[0];
if (!file) return;
setPhotoFile(file);
setPhotoPreview(URL.createObjectURL(file));
}
const buildPayload = () => ({
name: form.name.trim(),
sku: form.sku.trim() || null,
category: form.category,
description: form.description.trim() || null,
price: form.price !== "" ? parseFloat(form.price) : null,
currency: form.currency,
status: form.status,
active: form.status === "active",
costs: {
labor_hours: numOr(form.costs.labor_hours),
labor_rate: numOr(form.costs.labor_rate),
items: form.costs.items
.filter((it) => it.name.trim())
.map((it) => ({
name: it.name.trim(),
quantity: numOr(it.quantity, 1),
price: numOr(it.price_last || it.price),
price_last: numOr(it.price_last || it.price),
price_min: numOr(it.price_min),
price_max: numOr(it.price_max),
})),
},
stock: {
on_hand: parseInt(form.stock.on_hand, 10) || 0,
reserved: parseInt(form.stock.reserved, 10) || 0,
},
});
const handleSave = async () => {
if (!form.name.trim()) {
setError("Product name is required.");
return;
}
setSaving(true);
setError("");
try {
let savedProduct;
if (isEdit) {
savedProduct = await api.put(`/crm/products/${id}`, buildPayload());
} else {
savedProduct = await api.post("/crm/products", buildPayload());
}
// Upload photo if a new one was selected
if (photoFile && savedProduct?.id) {
setUploadingPhoto(true);
const formData = new FormData();
formData.append("file", photoFile);
await fetch(`/api/crm/products/${savedProduct.id}/photo`, {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
body: formData,
});
}
navigate("/crm/products");
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
setUploadingPhoto(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await api.delete(`/crm/products/${id}`);
navigate("/crm/products");
} catch (err) {
setError(err.message);
setSaving(false);
}
};
const costsTotalEst = computeCostsTotal(form.costs, "price_last");
const costsTotalMin = computeCostsTotal(form.costs, "price_min");
const costsTotalMax = computeCostsTotal(form.costs, "price_max");
const price = parseFloat(form.price) || 0;
const marginEst = price - costsTotalEst;
const marginMin = price - costsTotalMax; // highest cost = lowest margin
const marginMax = price - costsTotalMin; // lowest cost = highest margin
const stockAvailable = (parseInt(form.stock.on_hand, 10) || 0) - (parseInt(form.stock.reserved, 10) || 0);
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
const currentPhoto = photoPreview || existingPhotoUrl;
return (
<div style={{ maxWidth: 1300, margin: "0 auto" }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Product" : "New Product"}
</h1>
<div className="flex items-center gap-2">
{isEdit && canEdit && !showDeleteConfirm && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger)", backgroundColor: "transparent" }}
>
Delete
</button>
)}
{isEdit && canEdit && showDeleteConfirm && (
<>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>Are you sure?</span>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</>
)}
{!showDeleteConfirm && (
<>
<button
onClick={() => navigate("/crm/products")}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.7 : 1 }}
>
{saving ? (uploadingPhoto ? "Uploading photo..." : "Saving...") : "Save"}
</button>
)}
</>
)}
</div>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{/* 2-column layout */}
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
{/* LEFT column */}
<div style={{ flex: "0 0 460px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Product Details */}
<SectionCard title="Product Details">
{/* Photo upload */}
<div style={{ display: "flex", gap: 16, alignItems: "flex-start", marginBottom: 16 }}>
<div
onClick={() => canEdit && fileInputRef.current?.click()}
style={{
width: 120, height: 120, borderRadius: 10, border: "2px dashed var(--border-primary)",
backgroundColor: "var(--bg-primary)", display: "flex", alignItems: "center",
justifyContent: "center", overflow: "hidden", flexShrink: 0,
cursor: canEdit ? "pointer" : "default", position: "relative",
}}
>
{currentPhoto ? (
<img src={currentPhoto} alt="" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
) : (
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 24, opacity: 0.3 }}>📷</div>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginTop: 4 }}>Photo</div>
</div>
)}
</div>
<div style={{ flex: 1 }}>
{canEdit && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
{currentPhoto ? "Change Photo" : "Upload Photo"}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: "none" }}
onChange={handlePhotoChange}
/>
{photoFile && (
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
{photoFile.name} will upload on save
</div>
)}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
JPG, PNG, or WebP. Stored on server.
</div>
</div>
</div>
{/* Name + SKU */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Name *">
<input
className={inputClass}
style={inputStyle}
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Vesper Plus"
/>
</Field>
<Field label="SKU">
<input
className={inputClass}
style={inputStyle}
value={form.sku}
onChange={(e) => set("sku", e.target.value)}
placeholder="e.g. VSP-001"
/>
</Field>
</div>
{/* Category */}
<div style={{ marginBottom: 14 }}>
<Field label="Category">
<select
className={inputClass}
style={inputStyle}
value={form.category}
onChange={(e) => set("category", e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
</Field>
</div>
{/* Description */}
<div style={{ marginBottom: 14 }}>
<Field label="Description">
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 72 }}
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="Optional product description..."
/>
</Field>
</div>
{/* Status toggle — color coded */}
<div>
<div className="ui-form-label">Status</div>
<div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-primary)" }}>
{STATUS_OPTIONS.map((opt, idx) => {
const isActive = form.status === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => canEdit && set("status", opt.value)}
style={{
flex: 1, padding: "7px 0", fontSize: 12, fontWeight: 600,
border: "none", cursor: canEdit ? "pointer" : "default",
backgroundColor: isActive ? opt.activeBg : opt.inactiveBg,
color: isActive ? opt.activeColor : opt.inactiveColor,
borderRight: idx < STATUS_OPTIONS.length - 1 ? "1px solid var(--border-primary)" : "none",
transition: "background-color 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
</div>
</SectionCard>
{/* Stock */}
<SectionCard title="Stock">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
<Field label="On Hand">
<input
type="number" min="0" step="1"
className={inputClass} style={inputStyle}
value={form.stock.on_hand}
onChange={(e) => setStock("on_hand", e.target.value)}
placeholder="0"
/>
</Field>
<Field label="Reserved">
<input
type="number" min="0" step="1"
className={inputClass} style={inputStyle}
value={form.stock.reserved}
onChange={(e) => setStock("reserved", e.target.value)}
placeholder="0"
/>
</Field>
<Field label="Available">
<div
className="px-3 py-2 text-sm rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-heading)", fontWeight: 600 }}
>
{stockAvailable}
</div>
</Field>
</div>
</SectionCard>
</div>
{/* RIGHT column */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 16 }}>
{/* Pricing & Mfg. Costs */}
<SectionCard title="Pricing & Mfg. Costs">
{/* Price */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Price (EUR)">
<input
type="number" min="0" step="0.01"
className={inputClass} style={inputStyle}
value={form.price}
onChange={(e) => set("price", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) set("price", v.toFixed(2)); }}
placeholder="0.00"
/>
</Field>
<div />
</div>
{/* Labor */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
<Field label="Labor Hours">
<input type="number" min="0" step="0.5" className={inputClass} style={inputStyle}
value={form.costs.labor_hours} onChange={(e) => setCost("labor_hours", e.target.value)} placeholder="0" />
</Field>
<Field label="Labor Rate (€/hr)">
<input type="number" min="0" step="0.01" className={inputClass} style={inputStyle}
value={form.costs.labor_rate} onChange={(e) => setCost("labor_rate", e.target.value)} placeholder="0.00" />
</Field>
</div>
{/* Cost line items */}
{form.costs.items.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 4 }}>
<div className="ui-form-label" style={{ marginBottom: 0 }}>Item Name</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>QTY</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Min ()</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Max ()</div>
<div className="ui-form-label" style={{ marginBottom: 0, textAlign: "center" }}>Est. ()</div>
<div />
</div>
{form.costs.items.map((it, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 72px 85px 85px 85px 28px", gap: 6, marginBottom: 6 }}>
<input className={inputClass} style={inputStyle} value={it.name}
onChange={(e) => setCostItem(i, "name", e.target.value)} placeholder="e.g. PCB" />
<input type="number" min="0" step="1" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.quantity}
onChange={(e) => setCostItem(i, "quantity", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "quantity", v.toFixed(2)); }}
placeholder="1" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_min}
onChange={(e) => setCostItem(i, "price_min", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_min", v.toFixed(2)); }}
placeholder="0.00" title="Minimum expected price" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_max}
onChange={(e) => setCostItem(i, "price_max", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_max", v.toFixed(2)); }}
placeholder="0.00" title="Maximum expected price" />
<input type="number" min="0" step="0.01" className={inputClass}
style={{ ...inputStyle, textAlign: "center" }}
value={it.price_last}
onChange={(e) => setCostItem(i, "price_last", e.target.value)}
onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, "price_last", v.toFixed(2)); }}
placeholder="0.00" title="Estimated / last paid price" />
<button type="button" onClick={() => removeCostItem(i)}
className="flex items-center justify-center text-base cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}>
×
</button>
</div>
))}
</div>
)}
<button type="button" onClick={addCostItem}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80 mb-4"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>
+ Add Cost Item
</button>
{/* Summary rows */}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{/* Mfg. Cost Total — three values */}
<div className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
<div className="flex items-center justify-between">
<span>Mfg. Cost Total</span>
<div className="flex items-center gap-2 font-mono text-xs">
<span style={{ color: "var(--text-more-muted)" }} title="MinMax cost range">
{costsTotalMin.toFixed(2)} {costsTotalMax.toFixed(2)}
</span>
<span style={{ color: "var(--border-primary)" }}>|</span>
<span className="font-semibold" style={{ color: "var(--text-heading)" }} title="Estimated cost">
est. {costsTotalEst.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Margin — three values */}
<div className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)" }}>
<div className="flex items-center justify-between">
<span>Margin (Price Cost)</span>
{form.price ? (
<div className="flex items-center gap-2 font-mono text-xs">
<span style={{ color: (marginMin >= 0 && marginMax >= 0) ? "var(--text-more-muted)" : "var(--danger-text)" }} title="Margin range (minmax cost)">
{marginMax.toFixed(2)} {marginMin.toFixed(2)}
</span>
<span style={{ color: "var(--border-primary)" }}>|</span>
<span className="font-semibold" style={{ color: marginEst >= 0 ? "var(--success-text)" : "var(--danger-text)" }} title="Estimated margin">
est. {marginEst.toFixed(2)}
</span>
</div>
) : (
<span className="font-mono font-semibold" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
</div>
</SectionCard>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CATEGORY_LABELS = {
controller: "Controller",
striker: "Striker",
clock: "Clock",
part: "Part",
repair_service: "Repair / Service",
};
const CATEGORIES = Object.keys(CATEGORY_LABELS);
export default function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fetchProducts = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (categoryFilter) params.set("category", categoryFilter);
const qs = params.toString();
const data = await api.get(`/crm/products${qs ? `?${qs}` : ""}`);
setProducts(data.products);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, [categoryFilter]);
const filtered = search
? products.filter(
(p) =>
(p.name || "").toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
)
: products;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Products
</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/products/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Product
</button>
)}
</div>
{/* Filters */}
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name or SKU..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-md border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 text-sm rounded-md border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}}
>
<option value="">All Categories</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No products found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-3" style={{ width: 48 }} />
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>SKU</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Category</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Price</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Mfg. Cost</th>
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Margin</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Stock</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
</tr>
</thead>
<tbody>
{filtered.map((p, index) => {
const price = p.price != null ? Number(p.price) : null;
const mfgCost = p.costs?.total != null ? Number(p.costs.total) : null;
const margin = price != null && mfgCost != null ? price - mfgCost : null;
return (
<tr
key={p.id}
onClick={() => navigate(`/crm/products/${p.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < filtered.length - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: hoveredRow === p.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(p.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-3 py-2" style={{ width: 48 }}>
{p.photo_url ? (
<img
src={`/api${p.photo_url}`}
alt=""
style={{ width: 40, height: 40, borderRadius: 6, objectFit: "contain", border: "1px solid var(--border-primary)" }}
/>
) : (
<div style={{ width: 40, height: 40, borderRadius: 6, backgroundColor: "var(--bg-card-hover)", border: "1px solid var(--border-primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontSize: 18, opacity: 0.4 }}>📦</span>
</div>
)}
</td>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>{p.name}</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>{p.sku || "—"}</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
{CATEGORY_LABELS[p.category] || p.category}
</span>
</td>
<td className="px-4 py-3 text-right" style={{ color: "var(--text-primary)" }}>
{price != null ? `${price.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3 text-right" style={{ color: "var(--text-muted)" }}>
{mfgCost != null ? `${mfgCost.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3 text-right" style={{ color: margin != null ? (margin >= 0 ? "var(--success-text)" : "var(--danger-text)") : "var(--text-muted)" }}>
{margin != null ? `${margin.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{p.stock ? p.stock.available : "—"}
</td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
p.active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{p.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as ProductList } from "./ProductList";
export { default as ProductForm } from "./ProductForm";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,438 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
const STATUS_STYLES = {
draft: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
sent: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
accepted: { bg: "var(--success-bg)", color: "var(--success-text)" },
rejected: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
function fmt(n) {
const f = parseFloat(n) || 0;
return f.toLocaleString("el-GR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
}
function fmtDate(iso) {
if (!iso) return "—";
return iso.slice(0, 10);
}
// ── PDF thumbnail via PDF.js ──────────────────────────────────────────────────
function loadPdfJs() {
return new Promise((res, rej) => {
if (window.pdfjsLib) { res(); return; }
if (document.getElementById("__pdfjs2__")) {
// Script already injected — wait for it
const check = setInterval(() => {
if (window.pdfjsLib) { clearInterval(check); res(); }
}, 50);
return;
}
const s = document.createElement("script");
s.id = "__pdfjs2__";
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
s.onload = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
res();
};
s.onerror = rej;
document.head.appendChild(s);
});
}
function PdfThumbnail({ quotationId, onClick }) {
const canvasRef = useRef(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await loadPdfJs();
const token = localStorage.getItem("access_token");
const url = `/api/crm/quotations/${quotationId}/pdf`;
const loadingTask = window.pdfjsLib.getDocument({
url,
httpHeaders: token ? { Authorization: `Bearer ${token}` } : {},
});
const pdf = await loadingTask.promise;
if (cancelled) return;
const page = await pdf.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const viewport = page.getViewport({ scale: 1 });
const scale = Math.min(72 / viewport.width, 96 / viewport.height);
const scaled = page.getViewport({ scale });
canvas.width = scaled.width;
canvas.height = scaled.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => { cancelled = true; };
}, [quotationId]);
const style = {
width: 72,
height: 96,
borderRadius: 4,
overflow: "hidden",
flexShrink: 0,
cursor: "pointer",
border: "1px solid var(--border-primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "var(--bg-primary)",
};
if (failed) {
return (
<div style={style} onClick={onClick} title="Open PDF">
<span style={{ fontSize: 28 }}>📑</span>
</div>
);
}
return (
<div style={style} onClick={onClick} title="Open PDF">
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }} />
</div>
);
}
function DraftThumbnail() {
return (
<div style={{
width: 72, height: 96, borderRadius: 4, flexShrink: 0,
border: "1px dashed var(--border-primary)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
backgroundColor: "var(--bg-primary)", gap: 4,
}}>
<span style={{ fontSize: 18 }}>📄</span>
<span style={{ fontSize: 9, fontWeight: 700, color: "var(--text-muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
DRAFT
</span>
</div>
);
}
function PdfViewModal({ quotationId, quotationNumber, onClose }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loadingPdf, setLoadingPdf] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let objectUrl = null;
const token = localStorage.getItem("access_token");
fetch(`/api/crm/quotations/${quotationId}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then(r => {
if (!r.ok) throw new Error("Failed to load PDF");
return r.blob();
})
.then(blob => {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
})
.catch(() => setError(true))
.finally(() => setLoadingPdf(false));
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [quotationId]);
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 1000,
backgroundColor: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
backgroundColor: "var(--bg-card)", borderRadius: 10, overflow: "hidden",
width: "80vw", height: "88vh", display: "flex", flexDirection: "column",
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
}}
>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 16px", borderBottom: "1px solid var(--border-primary)", flexShrink: 0,
}}>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>{quotationNumber}</span>
<div style={{ display: "flex", gap: 8 }}>
{blobUrl && (
<a
href={blobUrl}
download={`${quotationNumber}.pdf`}
style={{ padding: "4px 12px", fontSize: 12, borderRadius: 6, textDecoration: "none", backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Download
</a>
)}
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "var(--text-muted)", fontSize: 22, cursor: "pointer", lineHeight: 1, padding: "0 4px" }}
>×</button>
</div>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
{loadingPdf && <span style={{ color: "var(--text-muted)", fontSize: 13 }}>Loading PDF...</span>}
{error && <span style={{ color: "var(--danger-text)", fontSize: 13 }}>Failed to load PDF.</span>}
{blobUrl && (
<iframe
src={blobUrl}
style={{ width: "100%", height: "100%", border: "none" }}
title={quotationNumber}
/>
)}
</div>
</div>
</div>
);
}
export default function QuotationList({ customerId, onSend }) {
const navigate = useNavigate();
const [quotations, setQuotations] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(null);
const [regenerating, setRegenerating] = useState(null);
const [pdfPreview, setPdfPreview] = useState(null); // { id, number }
const load = useCallback(async () => {
if (!customerId) return;
setLoading(true);
try {
const res = await api.get(`/crm/quotations/customer/${customerId}`);
setQuotations(res.quotations || []);
} catch {
setQuotations([]);
} finally {
setLoading(false);
}
}, [customerId]);
useEffect(() => { load(); }, [load]);
async function handleDelete(q) {
if (!window.confirm(`Delete quotation ${q.quotation_number}? This cannot be undone.`)) return;
setDeleting(q.id);
try {
await api.delete(`/crm/quotations/${q.id}`);
setQuotations(prev => prev.filter(x => x.id !== q.id));
} catch {
alert("Failed to delete quotation");
} finally {
setDeleting(null);
}
}
async function handleRegenerate(q) {
setRegenerating(q.id);
try {
const updated = await api.post(`/crm/quotations/${q.id}/regenerate-pdf`);
setQuotations(prev => prev.map(x => x.id === updated.id ? {
...x,
nextcloud_pdf_url: updated.nextcloud_pdf_url,
} : x));
} catch {
alert("PDF regeneration failed");
} finally {
setRegenerating(null);
}
}
function openPdfModal(q) {
setPdfPreview({ id: q.id, number: q.quotation_number });
}
// Grid columns: thumbnail | number | title | date | status | total | actions
const GRID = "90px 120px minmax(0,1fr) 130px 130px 130px 120px";
return (
<div>
{/* PDF Preview Modal */}
{pdfPreview && (
<PdfViewModal
quotationId={pdfPreview.id}
quotationNumber={pdfPreview.number}
onClose={() => setPdfPreview(null)}
/>
)}
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ fontSize: 15, fontWeight: 600, color: "var(--text-heading)" }}>Quotations</h2>
<button
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
style={{
padding: "7px 16px", fontSize: 13, fontWeight: 600, borderRadius: 6,
border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff",
}}
>
+ New Quotation
</button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: 40, color: "var(--text-muted)", fontSize: 13 }}>Loading...</div>
)}
{!loading && quotations.length === 0 && (
<div style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8 }}>
<div style={{ fontSize: 32, marginBottom: 10 }}>📄</div>
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--text-heading)", marginBottom: 6 }}>No quotations yet</div>
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
Create a quotation to generate a professional PDF offer for this customer.
</div>
<button
onClick={() => navigate(`/crm/quotations/new?customer_id=${customerId}`)}
style={{ padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 6, border: "none", cursor: "pointer", backgroundColor: "var(--accent)", color: "#fff" }}
>
+ New Quotation
</button>
</div>
)}
{!loading && quotations.length > 0 && (
<div style={{ borderRadius: 8, border: "1px solid var(--border-primary)", overflow: "hidden" }}>
{/* Table header */}
<div style={{
display: "grid",
gridTemplateColumns: GRID,
backgroundColor: "var(--bg-card)",
borderBottom: "1px solid var(--border-primary)",
padding: "8px 16px",
alignItems: "center",
gap: 12,
}}>
<div />
{[
{ label: "Number", align: "left" },
{ label: "Title", align: "left" },
{ label: "Date", align: "center" },
{ label: "Status", align: "center" },
{ label: "Total", align: "right", paddingRight: 16 },
{ label: "Actions", align: "center" },
].map(({ label, align, paddingRight }) => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", textAlign: align, paddingRight }}>
{label}
</div>
))}
</div>
{/* Rows */}
{quotations.map(q => (
<div
key={q.id}
style={{
display: "grid",
gridTemplateColumns: GRID,
gap: 12,
padding: "12px 16px",
borderBottom: "1px solid var(--border-secondary)",
alignItems: "center",
minHeight: 110,
backgroundColor: "var(--bg-card)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--bg-card)"}
>
{/* Thumbnail — click opens modal if PDF exists */}
<div>
{q.nextcloud_pdf_url ? (
<PdfThumbnail quotationId={q.id} onClick={() => openPdfModal(q)} />
) : (
<DraftThumbnail />
)}
</div>
{/* Number */}
<div style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>
{q.quotation_number}
</div>
{/* Title + subtitle */}
<div style={{ overflow: "hidden", paddingRight: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{q.title || <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontWeight: 400 }}>Untitled</span>}
</div>
{q.subtitle && (
<div style={{ fontSize: 12, color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginTop: 2 }}>
{q.subtitle}
</div>
)}
</div>
{/* Date */}
<div style={{ fontSize: 12, color: "var(--text-secondary)", textAlign: "center" }}>
{fmtDate(q.created_at)}
</div>
{/* Status badge */}
<div style={{ textAlign: "center" }}>
<span style={{
display: "inline-block", padding: "2px 10px", borderRadius: 20,
fontSize: 11, fontWeight: 600,
backgroundColor: STATUS_STYLES[q.status]?.bg || "var(--bg-card-hover)",
color: STATUS_STYLES[q.status]?.color || "var(--text-secondary)",
}}>
{q.status}
</span>
</div>
{/* Total */}
<div style={{ fontSize: 16, fontWeight: 600, color: "var(--success-text)", textAlign: "right", paddingRight: 16 }}>
{fmt(q.final_total)}
</div>
{/* Actions — Edit + Delete same width; Gen PDF if no PDF yet */}
<div style={{ display: "flex", flexDirection: "column", gap: 5, alignItems: "stretch", paddingLeft: 25, paddingRight: 25 }}>
{!q.nextcloud_pdf_url && (
<button
onClick={() => handleRegenerate(q)}
disabled={regenerating === q.id}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-muted)", whiteSpace: "nowrap", textAlign: "center" }}
>
{regenerating === q.id ? "..." : "Gen PDF"}
</button>
)}
<button
onClick={() => navigate(`/crm/quotations/${q.id}`)}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--text-secondary)", whiteSpace: "nowrap", textAlign: "center" }}
>
Edit
</button>
<button
onClick={() => onSend && onSend(q)}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--accent)", whiteSpace: "nowrap", textAlign: "center" }}
>
Send
</button>
<button
onClick={() => handleDelete(q)}
disabled={deleting === q.id}
style={{ padding: "4px 10px", fontSize: 11, fontWeight: 500, borderRadius: 5, border: "1px solid var(--border-primary)", cursor: "pointer", backgroundColor: "transparent", color: "var(--danger-text)", whiteSpace: "nowrap", textAlign: "center" }}
>
{deleting === q.id ? "..." : "Delete"}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as QuotationForm } from "./QuotationForm";
export { default as QuotationList } from "./QuotationList";

File diff suppressed because it is too large Load Diff

View File

@@ -1599,6 +1599,7 @@ export default function DeviceDetail() {
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null); const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false); const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
const lastStrikeRequestAtRef = useRef(0); const lastStrikeRequestAtRef = useRef(0);
const [hwProduct, setHwProduct] = useState(null);
// --- Section edit modal open/close state --- // --- Section edit modal open/close state ---
const [editingLocation, setEditingLocation] = useState(false); const [editingLocation, setEditingLocation] = useState(false);
@@ -1641,6 +1642,25 @@ export default function DeviceDetail() {
setDeviceUsers([]); setDeviceUsers([]);
}).finally(() => setUsersLoading(false)); }).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (d.device_id) {
Promise.all([
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
if (!hwType) return;
const products = productsRes?.products || [];
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const normHw = norm(hwType);
const match = products.find(
(p) => norm(p.name) === normHw || norm(p.sku) === normHw ||
norm(p.name).includes(normHw) || normHw.includes(norm(p.name))
);
if (match) setHwProduct(match);
}).catch(() => {});
}
api.get(`/equipment/notes?device_id=${id}`).then((data) => { api.get(`/equipment/notes?device_id=${id}`).then((data) => {
const issues = (data.notes || []).filter( const issues = (data.notes || []).filter(
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed" (n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
@@ -1809,9 +1829,8 @@ export default function DeviceDetail() {
: null; : null;
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id); const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
const hwImageMap = { VesperPlus: "/devices/VesperPlus.png" }; const hwVariant = hwProduct?.name || "VesperPlus";
const hwVariant = "VesperPlus"; const hwImage = hwProduct?.photo_url ? `/api${hwProduct.photo_url}` : "/devices/VesperPlus.png";
const hwImage = hwImageMap[hwVariant] || hwImageMap.VesperPlus;
const locationCard = ( const locationCard = (
<section className="device-section-card"> <section className="device-section-card">

View File

@@ -250,16 +250,13 @@ export default function DeviceForm() {
{/* ===== Left Column ===== */} {/* ===== Left Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Basic Info --- */} {/* --- Basic Info --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Basic Information</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Basic Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Device Name * Device Name *
</label> </label>
<input <input
@@ -271,7 +268,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Location Location
</label> </label>
<input <input
@@ -283,7 +280,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Location Coordinates Location Coordinates
</label> </label>
<input <input
@@ -295,7 +292,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Device Photo URL Device Photo URL
</label> </label>
<input <input
@@ -320,13 +317,10 @@ export default function DeviceForm() {
</section> </section>
{/* --- Device Attributes --- */} {/* --- Device Attributes --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Device Attributes</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Device Attributes
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-wrap gap-4 md:col-span-2"> <div className="flex flex-wrap gap-4 md:col-span-2">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
@@ -385,7 +379,7 @@ export default function DeviceForm() {
</label> </label>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Total Bells Total Bells
</label> </label>
<input <input
@@ -397,7 +391,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Device Locale Device Locale
</label> </label>
<select <select
@@ -413,7 +407,7 @@ export default function DeviceForm() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Bell Outputs (comma-separated) Bell Outputs (comma-separated)
</label> </label>
<input <input
@@ -425,7 +419,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Hammer Timings (comma-separated) Hammer Timings (comma-separated)
</label> </label>
<input <input
@@ -437,7 +431,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Serial Log Level Serial Log Level
</label> </label>
<input <input
@@ -449,7 +443,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
SD Log Level SD Log Level
</label> </label>
<input <input
@@ -464,16 +458,13 @@ export default function DeviceForm() {
</section> </section>
{/* --- Network Settings --- */} {/* --- Network Settings --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Network Settings</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Network Settings
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Hostname Hostname
</label> </label>
<input <input
@@ -496,7 +487,7 @@ export default function DeviceForm() {
</label> </label>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
WebSocket URL WebSocket URL
</label> </label>
<input <input
@@ -507,7 +498,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Church Assistant URL Church Assistant URL
</label> </label>
<input <input
@@ -524,16 +515,13 @@ export default function DeviceForm() {
{/* ===== Right Column ===== */} {/* ===== Right Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Subscription --- */} {/* --- Subscription --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Subscription</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Subscription
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Tier Tier
</label> </label>
<select <select
@@ -549,7 +537,7 @@ export default function DeviceForm() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Start Date Start Date
</label> </label>
<input <input
@@ -560,7 +548,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Duration (months) Duration (months)
</label> </label>
<input <input
@@ -572,7 +560,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Max Users Max Users
</label> </label>
<input <input
@@ -584,7 +572,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Max Outputs Max Outputs
</label> </label>
<input <input
@@ -599,16 +587,13 @@ export default function DeviceForm() {
</section> </section>
{/* --- Clock Settings --- */} {/* --- Clock Settings --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Clock Settings</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Clock Settings
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Ring Alerts Ring Alerts
</label> </label>
<select <select
@@ -624,7 +609,7 @@ export default function DeviceForm() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Ring Intervals Ring Intervals
</label> </label>
<input <input
@@ -645,7 +630,7 @@ export default function DeviceForm() {
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span> <span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Clock Outputs (comma-separated) Clock Outputs (comma-separated)
</label> </label>
<input <input
@@ -656,7 +641,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Clock Timings (comma-separated) Clock Timings (comma-separated)
</label> </label>
<input <input
@@ -667,7 +652,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Hour Alerts Bell Hour Alerts Bell
</label> </label>
<input <input
@@ -679,7 +664,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Half-hour Alerts Bell Half-hour Alerts Bell
</label> </label>
<input <input
@@ -691,7 +676,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Quarter Alerts Bell Quarter Alerts Bell
</label> </label>
<input <input
@@ -703,7 +688,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Backlight Output Backlight Output
</label> </label>
<input <input
@@ -788,16 +773,13 @@ export default function DeviceForm() {
</section> </section>
{/* --- Statistics --- */} {/* --- Statistics --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Statistics & Warranty</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Statistics & Warranty
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Total Playbacks Total Playbacks
</label> </label>
<input <input
@@ -809,7 +791,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Total Hammer Strikes Total Hammer Strikes
</label> </label>
<input <input
@@ -821,7 +803,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Total Warnings Given Total Warnings Given
</label> </label>
<input <input
@@ -833,7 +815,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Per-Bell Strikes (comma-separated) Per-Bell Strikes (comma-separated)
</label> </label>
<input <input
@@ -853,7 +835,7 @@ export default function DeviceForm() {
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span> <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Warranty Start Warranty Start
</label> </label>
<input <input
@@ -864,7 +846,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Warranty Period (months) Warranty Period (months)
</label> </label>
<input <input
@@ -876,7 +858,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Last Maintained On Last Maintained On
</label> </label>
<input <input
@@ -887,7 +869,7 @@ export default function DeviceForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Maintenance Period (months) Maintenance Period (months)
</label> </label>
<input <input

View File

@@ -20,12 +20,7 @@ const categoryStyle = (cat) => {
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<div> <div>
<dt <dt className="ui-field-label">{label}</dt>
className="text-xs font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{label}
</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}> <dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
{children || "-"} {children || "-"}
</dd> </dd>
@@ -158,16 +153,10 @@ export default function NoteDetail() {
{/* Left Column */} {/* Left Column */}
<div className="space-y-6"> <div className="space-y-6">
{/* Note Content */} {/* Note Content */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Content</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Content
</h2>
<div <div
className="text-sm whitespace-pre-wrap" className="text-sm whitespace-pre-wrap"
style={{ color: "var(--text-primary)" }} style={{ color: "var(--text-primary)" }}
@@ -177,16 +166,10 @@ export default function NoteDetail() {
</section> </section>
{/* Timestamps */} {/* Timestamps */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Details</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Details
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Category"> <Field label="Category">
<span <span
@@ -211,16 +194,10 @@ export default function NoteDetail() {
{/* Right Column */} {/* Right Column */}
<div className="space-y-6"> <div className="space-y-6">
{/* Linked Device */} {/* Linked Device */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Linked Device</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Linked Device
</h2>
{note.device_id ? ( {note.device_id ? (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -247,16 +224,10 @@ export default function NoteDetail() {
</section> </section>
{/* Linked User */} {/* Linked User */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Linked User</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Linked User
</h2>
{note.user_id ? ( {note.user_id ? (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@@ -132,16 +132,13 @@ export default function NoteForm() {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column — Note Content */} {/* Left Column — Note Content */}
<div className="space-y-6"> <div className="space-y-6">
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Note Details</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Note Details
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Title * Title *
</label> </label>
<input <input
@@ -159,7 +156,7 @@ export default function NoteForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Content * Content *
</label> </label>
<textarea <textarea
@@ -178,7 +175,7 @@ export default function NoteForm() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Category Category
</label> </label>
<select <select
@@ -204,16 +201,13 @@ export default function NoteForm() {
{/* Right Column — Associations */} {/* Right Column — Associations */}
<div className="space-y-6"> <div className="space-y-6">
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Link To</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Link To
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Device (optional) Device (optional)
</label> </label>
<select <select
@@ -235,7 +229,7 @@ export default function NoteForm() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
User (optional) User (optional)
</label> </label>
<select <select

View File

@@ -3,9 +3,13 @@ import { useAuth } from "../auth/AuthContext";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" }, { value: "vesper", label: "Vesper" },
{ value: "vp", label: "Vesper+ (VP)" }, { value: "vesper_plus", label: "Vesper+" },
{ value: "vx", label: "VesperPro (VX)" }, { value: "vesper_pro", label: "Vesper Pro" },
{ value: "chronos", label: "Chronos" },
{ value: "chronos_pro", label: "Chronos Pro" },
{ value: "agnus", label: "Agnus" },
{ value: "agnus_mini", label: "Agnus Mini" },
]; ];
const CHANNELS = ["stable", "beta", "alpha", "testing"]; const CHANNELS = ["stable", "beta", "alpha", "testing"];
@@ -29,6 +33,24 @@ function ChannelBadge({ channel }) {
); );
} }
const UPDATE_TYPE_STYLES = {
mandatory: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", label: "Mandatory" },
emergency: { bg: "var(--danger-bg)", color: "var(--danger-text)", label: "Emergency" },
optional: { bg: "var(--bg-card-hover)", color: "var(--text-muted)", label: "Optional" },
};
function UpdateTypeBadge({ type }) {
const style = UPDATE_TYPE_STYLES[type] || UPDATE_TYPE_STYLES.optional;
return (
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{style.label}
</span>
);
}
function formatBytes(bytes) { function formatBytes(bytes) {
if (!bytes) return "—"; if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -61,9 +83,11 @@ export default function FirmwareManager() {
const [channelFilter, setChannelFilter] = useState(""); const [channelFilter, setChannelFilter] = useState("");
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [uploadHwType, setUploadHwType] = useState("vs"); const [uploadHwType, setUploadHwType] = useState("vesper");
const [uploadChannel, setUploadChannel] = useState("stable"); const [uploadChannel, setUploadChannel] = useState("stable");
const [uploadVersion, setUploadVersion] = useState(""); const [uploadVersion, setUploadVersion] = useState("");
const [uploadUpdateType, setUploadUpdateType] = useState("mandatory");
const [uploadMinFw, setUploadMinFw] = useState("");
const [uploadNotes, setUploadNotes] = useState(""); const [uploadNotes, setUploadNotes] = useState("");
const [uploadFile, setUploadFile] = useState(null); const [uploadFile, setUploadFile] = useState(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -104,6 +128,8 @@ export default function FirmwareManager() {
formData.append("hw_type", uploadHwType); formData.append("hw_type", uploadHwType);
formData.append("channel", uploadChannel); formData.append("channel", uploadChannel);
formData.append("version", uploadVersion); formData.append("version", uploadVersion);
formData.append("update_type", uploadUpdateType);
if (uploadMinFw) formData.append("min_fw_version", uploadMinFw);
if (uploadNotes) formData.append("notes", uploadNotes); if (uploadNotes) formData.append("notes", uploadNotes);
formData.append("file", uploadFile); formData.append("file", uploadFile);
@@ -120,6 +146,8 @@ export default function FirmwareManager() {
setShowUpload(false); setShowUpload(false);
setUploadVersion(""); setUploadVersion("");
setUploadUpdateType("mandatory");
setUploadMinFw("");
setUploadNotes(""); setUploadNotes("");
setUploadFile(null); setUploadFile(null);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
@@ -145,7 +173,7 @@ export default function FirmwareManager() {
} }
}; };
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
return ( return (
<div> <div>
@@ -173,131 +201,247 @@ export default function FirmwareManager() {
{/* Upload form */} {/* Upload form */}
{showUpload && ( {showUpload && (
<div <div
className="rounded-lg border p-5 mb-5" className="ui-section-card mb-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}> {/* Section title row */}
Upload New Firmware <div className="ui-section-card__title-row">
</h2> <h2 className="ui-section-card__title">Upload New Firmware</h2>
</div>
{uploadError && ( {uploadError && (
<div <div
className="text-sm rounded-md p-3 mb-3 border" className="text-sm rounded-md p-3 mb-4 border"
style={{ style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
> >
{uploadError} {uploadError}
</div> </div>
)} )}
<form onSubmit={handleUpload} className="space-y-3">
<div className="grid grid-cols-3 gap-3"> <form onSubmit={handleUpload}>
<div> {/* 3-column panel layout — height driven by left panel */}
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1.5rem", alignItems: "stretch" }}>
Board Type
</label> {/* ── LEFT: Config ── */}
<select <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
value={uploadHwType} {/* Board Type */}
onChange={(e) => setUploadHwType(e.target.value)} <div>
className="w-full px-3 py-2 rounded-md text-sm border" <label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
style={{ <select
backgroundColor: "var(--bg-input)", value={uploadHwType}
borderColor: "var(--border-input)", onChange={(e) => setUploadHwType(e.target.value)}
color: "var(--text-primary)", className="w-full px-3 py-2 rounded-md text-sm border"
}} style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
> >
{BOARD_TYPES.map((bt) => ( {BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option> <option key={bt.value} value={bt.value}>{bt.label}</option>
))} ))}
</select> </select>
</div>
{/* Channel | Version | Min FW */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.625rem" }}>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Channel</label>
<select
value={uploadChannel}
onChange={(e) => setUploadChannel(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Version</label>
<input
type="text"
value={uploadVersion}
onChange={(e) => setUploadVersion(e.target.value)}
placeholder="1.5"
required
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Min FW</label>
<input
type="text"
value={uploadMinFw}
onChange={(e) => setUploadMinFw(e.target.value)}
placeholder="e.g. 1.2"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
</div>
{/* Update Type — 3 toggle buttons */}
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Update Type</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.5rem" }}>
{[
{ value: "mandatory", label: "Mandatory", desc: "Auto on reboot", active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)" } },
{ value: "emergency", label: "Emergency", desc: "Immediate push", active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" } },
{ value: "optional", label: "Optional", desc: "User-initiated", active: { bg: "var(--success-bg)", border: "var(--success-text)", color: "var(--success-text)" } },
].map((opt) => {
const isActive = uploadUpdateType === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setUploadUpdateType(opt.value)}
style={{
padding: "0.5rem 0.25rem",
borderRadius: "0.5rem",
border: `1px solid ${isActive ? opt.active.border : "var(--border-input)"}`,
backgroundColor: isActive ? opt.active.bg : "var(--bg-input)",
color: isActive ? opt.active.color : "var(--text-muted)",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s ease",
}}
>
<div style={{ fontSize: "0.75rem", fontWeight: 600 }}>{opt.label}</div>
<div style={{ fontSize: "0.65rem", marginTop: "0.15rem", opacity: 0.75 }}>{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Action buttons sit at the bottom of the left panel */}
<div style={{ display: "flex", gap: "0.625rem", marginTop: "auto", paddingTop: "0.5rem" }}>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Upload"}
</button>
<button
type="button"
onClick={() => { setShowUpload(false); setUploadError(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div> </div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}> {/* ── MIDDLE: Release Notes ── */}
Channel <div style={{ display: "flex", flexDirection: "column" }}>
</label> <label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>Release Notes</label>
<select <textarea
value={uploadChannel} value={uploadNotes}
onChange={(e) => setUploadChannel(e.target.value)} onChange={(e) => setUploadNotes(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border" placeholder="What changed in this version?"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Version
</label>
<input
type="text"
value={uploadVersion}
onChange={(e) => setUploadVersion(e.target.value)}
placeholder="1.4.2"
required
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={{
backgroundColor: "var(--bg-input)", backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)", borderColor: "var(--border-input)",
color: "var(--text-primary)", color: "var(--text-primary)",
flex: 1,
resize: "none",
minHeight: 0,
}} }}
/> />
</div> </div>
</div>
<div> {/* ── RIGHT: File drop + info ── */}
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}> <div style={{ display: "flex", flexDirection: "column" }}>
firmware.bin <label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>File Upload</label>
</label> {/* Drop zone */}
<input <div
ref={fileInputRef} onClick={() => fileInputRef.current?.click()}
type="file" onDragOver={(e) => e.preventDefault()}
accept=".bin" onDrop={(e) => {
required e.preventDefault();
onChange={(e) => setUploadFile(e.target.files[0] || null)} const f = e.dataTransfer.files[0];
className="w-full text-sm" if (f && f.name.endsWith(".bin")) setUploadFile(f);
style={{ color: "var(--text-primary)" }} }}
/> style={{
</div> flex: 1,
<div> display: "flex",
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}> flexDirection: "column",
Release Notes (optional) alignItems: "center",
</label> justifyContent: "center",
<textarea gap: "0.5rem",
value={uploadNotes} border: `2px dashed ${uploadFile ? "var(--btn-primary)" : "var(--border-input)"}`,
onChange={(e) => setUploadNotes(e.target.value)} borderRadius: "0.625rem",
rows={2} backgroundColor: uploadFile ? "var(--badge-blue-bg)" : "var(--bg-input)",
placeholder="What changed in this version?" cursor: "pointer",
className="w-full px-3 py-2 rounded-md text-sm border resize-none" transition: "all 0.15s ease",
style={{ padding: "1rem",
backgroundColor: "var(--bg-input)", minHeight: 0,
borderColor: "var(--border-input)", }}
color: "var(--text-primary)", >
}} <input
/> ref={fileInputRef}
</div> type="file"
<div className="flex gap-3 pt-1"> accept=".bin"
<button required
type="submit" onChange={(e) => setUploadFile(e.target.files[0] || null)}
disabled={uploading} style={{ display: "none" }}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50" />
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} {uploadFile ? (
> <>
{uploading ? "Uploading…" : "Upload"} <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
</button> <polyline points="20 6 9 17 4 12" />
<button </svg>
type="button" <span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
onClick={() => { setShowUpload(false); setUploadError(""); }} {uploadFile.name}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" </span>
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }} </>
> ) : (
Cancel <>
</button> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <strong>.bin</strong> file here
</span>
</>
)}
</div>
{/* File info */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.375rem",
padding: "0.75rem",
marginTop: "0.75rem",
borderRadius: "0.5rem",
border: "1px solid var(--border-secondary)",
backgroundColor: "var(--bg-secondary)",
}}
>
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Size</span>
{" "}
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
{uploadFile ? formatBytes(uploadFile.size) : "—"}
</span>
</div>
{uploadFile?.lastModified && (
<div style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>
<span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>Modified</span>
{" "}
<span style={{ color: "var(--text-primary)", fontFamily: "monospace" }}>
{formatDate(new Date(uploadFile.lastModified).toISOString())}
</span>
</div>
)}
</div>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -362,6 +506,8 @@ export default function FirmwareManager() {
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Update Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Min FW</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th> <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
@@ -374,13 +520,13 @@ export default function FirmwareManager() {
<tbody> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}> <td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
Loading Loading
</td> </td>
</tr> </tr>
) : firmware.length === 0 ? ( ) : firmware.length === 0 ? (
<tr> <tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}> <td colSpan={canDelete ? 10 : 9} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
No firmware versions found.{" "} No firmware versions found.{" "}
{canAdd && ( {canAdd && (
<button <button
@@ -408,6 +554,12 @@ export default function FirmwareManager() {
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}> <td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{fw.version} {fw.version}
</td> </td>
<td className="px-4 py-3">
<UpdateTypeBadge type={fw.update_type} />
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{fw.min_fw_version || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}> <td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{formatBytes(fw.size_bytes)} {formatBytes(fw.size_bytes)}
</td> </td>

View File

@@ -17,6 +17,7 @@
--text-primary: #e3e5ea; --text-primary: #e3e5ea;
--text-secondary: #9ca3af; --text-secondary: #9ca3af;
--text-muted: #9ca3af; --text-muted: #9ca3af;
--text-more-muted: #818da1bb;
--text-heading: #e3e5ea; --text-heading: #e3e5ea;
--text-white: #ffffff; --text-white: #ffffff;
--text-link: #589cfa; --text-link: #589cfa;
@@ -28,6 +29,11 @@
--btn-primary-hover: #82c91e; --btn-primary-hover: #82c91e;
--btn-neutral: #778ca8; --btn-neutral: #778ca8;
--btn-neutral-hover: #8a9dba; --btn-neutral-hover: #8a9dba;
--mail-filter-green: #2f9e44;
--mail-filter-blue: #4dabf7;
--mail-filter-yellow: #f08c00;
--mail-filter-orange: #f76707;
--mail-filter-red: #e03131;
--danger: #f34b4b; --danger: #f34b4b;
--danger-hover: #e53e3e; --danger-hover: #e53e3e;
@@ -42,6 +48,48 @@
--badge-blue-bg: #1e3a5f; --badge-blue-bg: #1e3a5f;
--badge-blue-text: #63b3ed; --badge-blue-text: #63b3ed;
/* ── Spacing tokens ── */
--section-padding: 2.25rem 2.5rem 2.25rem;
--section-padding-compact: 1.25rem 1.5rem;
--section-radius: 0.75rem;
--section-gap: 1.5rem;
/* ── Typography tokens ── */
--section-title-size: 0.78rem;
--section-title-weight: 700;
--section-title-tracking: 0.07em;
--font-size-label: 0.72rem;
--font-size-value: 0.92rem;
/* ── Section header title (larger, page-header style) ── */
--section-header-title-size: 1.0rem;
--section-header-title-weight: 600;
--section-header-title-tracking: 0.01em;
--section-header-title-color: var(--text-heading);
/* ── Field / item labels (secondary titles within sections) ── */
/* Display variant: small, uppercase, muted — used in <dt> / read-only views */
--field-label-size: 0.72rem;
--field-label-weight: 600;
--field-label-tracking: 0.02em;
--field-label-color: var(--text-more-muted);
/* Form variant: slightly larger, no uppercase — used in <label> / form inputs */
--form-label-size: 0.8rem;
--form-label-weight: 500;
--form-label-tracking: 0.01em;
--form-label-color: var(--text-secondary);
}
/* Remove number input spinners (arrows) in all browsers */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
} }
/* Ensure all interactive elements show pointer cursor */ /* Ensure all interactive elements show pointer cursor */
@@ -624,12 +672,77 @@ input[type="range"]::-moz-range-thumb {
text-align: left; text-align: left;
} }
/* ── Section cards (used in all tabs) ── */ /* ── Universal section card — single source of truth for all pages ── */
.ui-section-card {
border: 1px solid var(--border-primary);
border-radius: var(--section-radius);
background-color: var(--bg-card);
padding: var(--section-padding);
}
.ui-section-card--compact {
border: 1px solid var(--border-primary);
border-radius: var(--section-radius);
background-color: var(--bg-card);
padding: var(--section-padding-compact);
}
.ui-section-card__title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.ui-section-card__title {
font-size: var(--section-title-size);
font-weight: var(--section-title-weight);
letter-spacing: var(--section-title-tracking);
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
}
/* Larger, white-ish section title for form/settings pages */
.ui-section-card__header-title {
font-size: var(--section-header-title-size);
font-weight: var(--section-header-title-weight);
letter-spacing: var(--section-header-title-tracking);
color: var(--section-header-title-color);
margin: 0;
}
/* ── Field labels — secondary titles within section cards ── */
/* Display/read-only label (dt, small caps, muted) */
.ui-field-label {
display: block;
font-size: var(--field-label-size);
font-weight: var(--field-label-weight);
letter-spacing: var(--field-label-tracking);
text-transform: uppercase;
color: var(--field-label-color);
margin: 0;
}
/* Form label (label element, slightly larger, no uppercase) */
.ui-form-label {
display: block;
font-size: var(--form-label-size);
font-weight: var(--form-label-weight);
letter-spacing: var(--form-label-tracking);
color: var(--form-label-color);
margin-bottom: 0.35rem;
}
/* ── Section cards (used in all tabs) — alias to ui-section-card ── */
.device-section-card { .device-section-card {
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
border-radius: 0.75rem; border-radius: var(--section-radius);
background-color: var(--bg-card); background-color: var(--bg-card);
padding: 2.25rem 2.5rem 2.5rem; padding: var(--section-padding);
} }
.device-section-card__title-row { .device-section-card__title-row {
@@ -642,9 +755,9 @@ input[type="range"]::-moz-range-thumb {
} }
.device-section-card__title { .device-section-card__title {
font-size: 0.78rem; font-size: var(--section-title-size);
font-weight: 700; font-weight: var(--section-title-weight);
letter-spacing: 0.07em; letter-spacing: var(--section-title-tracking);
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0; margin-bottom: 0;
@@ -729,7 +842,7 @@ input[type="range"]::-moz-range-thumb {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; padding: 1rem 2.5rem;
border-radius: 0.6rem; border-radius: 0.6rem;
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
background-color: var(--bg-card); background-color: var(--bg-card);
@@ -1105,6 +1218,17 @@ input[type="range"]::-moz-range-thumb {
} }
/* ── Upload modal animations ── */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* File input */ /* File input */
input[type="file"]::file-selector-button { input[type="file"]::file-selector-button {
background-color: var(--bg-card) !important; background-color: var(--bg-card) !important;

View File

@@ -1,4 +1,202 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
// ── Breadcrumb label cache (in-memory, survives re-renders) ────────────────
const labelCache = {};
// ── Segment label rules ────────────────────────────────────────────────────
const STATIC_LABELS = {
"": "Home",
dashboard: "Dashboard",
devices: "Devices",
users: "App Users",
mqtt: "MQTT",
commands: "Commands",
logs: "Logs",
equipment: "Equipment",
notes: "Issues & Notes",
mail: "Mail",
crm: "CRM",
comms: "Activity Log",
customers: "Customers",
orders: "Orders",
products: "Products",
quotations: "Quotations",
new: "New",
edit: "Edit",
melodies: "Melodies",
archetypes: "Archetypes",
settings: "Settings",
composer: "Composer",
manufacturing: "Manufacturing",
batch: "Batch",
provision: "Provision Device",
firmware: "Firmware",
developer: "Developer",
api: "API Reference",
staff: "Staff",
};
// Maps a parent path segment to an async entity resolver
const ENTITY_RESOLVERS = {
customers: { apiPath: (id) => `/crm/customers/${id}`, label: (d) => [d.name, d.surname].filter(Boolean).join(" ") || d.organization || null },
products: { apiPath: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
quotations: { apiPath: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || null },
devices: { apiPath: (id) => `/devices/${id}`, label: (d) => d.name || d.device_id || null },
orders: { apiPath: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
melodies: { apiPath: (id) => `/melodies/${id}`, label: (d) => d.name || null },
archetypes: { apiPath: (id) => `/builder/melodies/${id}`,label: (d) => d.name || null },
users: { apiPath: (id) => `/users/${id}`, label: (d) => d.name || d.display_name || null },
notes: { apiPath: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
staff: { apiPath: (id) => `/staff/${id}`, label: (d) => d.name || null },
};
// Fetch entity name for a dynamic ID segment
async function fetchLabel(fetchType, id) {
const cacheKey = `${fetchType}:${id}`;
if (labelCache[cacheKey]) return labelCache[cacheKey];
const resolver = ENTITY_RESOLVERS[fetchType];
if (!resolver) return id;
try {
const data = await api.get(resolver.apiPath(id));
const label = resolver.label(data);
if (label) {
const short = String(label).length > 28 ? String(label).slice(0, 26) + "…" : String(label);
labelCache[cacheKey] = short;
return short;
}
} catch {
// ignore — fall back to id
}
return id;
}
// Given a full path, return breadcrumb segments: [{ label, to }]
function parseSegments(pathname) {
const parts = pathname.split("/").filter(Boolean);
const segments = [{ label: "Home", to: "/" }];
let i = 0;
while (i < parts.length) {
const part = parts[i];
const built = "/" + parts.slice(0, i + 1).join("/");
// Special multi-segment patterns
if (part === "crm") {
segments.push({ label: "CRM", to: "/crm/customers" });
i++;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "batch" && parts[i + 2] === "new") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
segments.push({ label: "New Batch", to: built + "/batch/new" });
i += 3;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "provision") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
segments.push({ label: "Provision Device", to: built + "/provision" });
i += 2;
continue;
}
if (part === "manufacturing" && parts[i + 1] === "devices") {
segments.push({ label: "Manufacturing", to: "/manufacturing" });
i++;
continue;
}
// "devices" is handled by STATIC_LABELS below so the following ID segment
// can detect prevPart === "devices" for entity resolution.
// equipment/notes
if (part === "equipment" && parts[i + 1] === "notes") {
segments.push({ label: "Issues & Notes", to: "/equipment/notes" });
i += 2;
continue;
}
const staticLabel = STATIC_LABELS[part];
if (staticLabel) {
segments.push({ label: staticLabel, to: built });
} else {
// Dynamic ID segment — determine type from previous path segment
const prevPart = parts[i - 1];
const fetchType = ENTITY_RESOLVERS[prevPart] ? prevPart : null;
// Use the raw id as placeholder — will be replaced asynchronously if fetchType is known
segments.push({ label: part, to: built, dynamicId: part, fetchType });
}
i++;
}
return segments;
}
function Breadcrumb() {
const location = useLocation();
const [segments, setSegments] = useState(() => parseSegments(location.pathname));
useEffect(() => {
const parsed = parseSegments(location.pathname);
setSegments(parsed);
// Resolve any dynamic segments asynchronously
const dynamics = parsed.filter((s) => s.fetchType && s.dynamicId);
if (dynamics.length === 0) return;
let cancelled = false;
(async () => {
const resolved = [...parsed];
for (const seg of dynamics) {
const label = await fetchLabel(seg.fetchType, seg.dynamicId);
if (cancelled) return;
const idx = resolved.findIndex((s) => s.dynamicId === seg.dynamicId && s.fetchType === seg.fetchType);
if (idx !== -1) resolved[idx] = { ...resolved[idx], label };
}
if (!cancelled) setSegments([...resolved]);
})();
return () => { cancelled = true; };
}, [location.pathname]);
// Don't show breadcrumb for root
if (segments.length <= 1) return null;
// Remove "Home" from display if not on root
const display = segments.slice(1);
return (
<nav aria-label="Breadcrumb" style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13 }}>
{/* Brand prefix */}
<span style={{ color: "var(--text-heading)", fontWeight: 700, fontSize: 13, letterSpacing: "0.01em", whiteSpace: "nowrap" }}>
BellSystems Console
</span>
<span style={{ color: "var(--border-primary)", userSelect: "none", margin: "0 8px", fontSize: 15 }}>|</span>
{display.map((seg, i) => (
<span key={i} style={{ display: "flex", alignItems: "center", gap: 4 }}>
{i > 0 && (
<span style={{ color: "var(--text-muted)", userSelect: "none", fontSize: 11 }}></span>
)}
{i === display.length - 1 ? (
<span style={{ color: "var(--text-heading)", fontWeight: 600 }}>{seg.label}</span>
) : (
<Link
to={seg.to}
style={{ color: "var(--text-secondary)", textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--text-heading)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-secondary)")}
>
{seg.label}
</Link>
)}
</span>
))}
</nav>
);
}
export default function Header() { export default function Header() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -11,9 +209,7 @@ export default function Header() {
borderColor: "var(--border-primary)", borderColor: "var(--border-primary)",
}} }}
> >
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}> <Breadcrumb />
BellCloud - Console
</h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}> <span className="text-sm" style={{ color: "var(--text-secondary)" }}>
@@ -41,5 +237,3 @@ export default function Header() {
</header> </header>
); );
} }
/* my test string */

View File

@@ -8,7 +8,7 @@ const navItems = [
label: "Melodies", label: "Melodies",
permission: "melodies", permission: "melodies",
children: [ children: [
{ to: "/melodies", label: "Main Editor" }, { to: "/melodies", label: "Main Editor", exact: true },
{ to: "/melodies/archetypes", label: "Archetypes" }, { to: "/melodies/archetypes", label: "Archetypes" },
{ to: "/melodies/settings", label: "Settings" }, { to: "/melodies/settings", label: "Settings" },
{ to: "/melodies/composer", label: "Composer" }, { to: "/melodies/composer", label: "Composer" },
@@ -20,17 +20,28 @@ const navItems = [
label: "MQTT", label: "MQTT",
permission: "mqtt", permission: "mqtt",
children: [ children: [
{ to: "/mqtt", label: "Dashboard" }, { to: "/mqtt", label: "Dashboard", exact: true },
{ to: "/mqtt/commands", label: "Commands" }, { to: "/mqtt/commands", label: "Commands" },
{ to: "/mqtt/logs", label: "Logs" }, { to: "/mqtt/logs", label: "Logs" },
], ],
}, },
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" }, { to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
{ to: "/mail", label: "Mail", permission: "crm" },
{
label: "CRM",
permission: "crm",
children: [
{ to: "/crm/comms", label: "Activity Log" },
{ to: "/crm/customers", label: "Customers" },
{ to: "/crm/orders", label: "Orders" },
{ to: "/crm/products", label: "Products" },
],
},
{ {
label: "Manufacturing", label: "Manufacturing",
permission: "manufacturing", permission: "manufacturing",
children: [ children: [
{ to: "/manufacturing", label: "Device Inventory" }, { to: "/manufacturing", label: "Device Inventory", exact: true },
{ to: "/manufacturing/batch/new", label: "New Batch" }, { to: "/manufacturing/batch/new", label: "New Batch" },
{ to: "/manufacturing/provision", label: "Provision Device" }, { to: "/manufacturing/provision", label: "Provision Device" },
{ to: "/firmware", label: "Firmware" }, { to: "/firmware", label: "Firmware" },
@@ -47,17 +58,39 @@ const linkClass = (isActive, locked) =>
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`; }`;
function isGroupActive(children, pathname) {
return children.some((child) => {
if (child.exact) return pathname === child.to;
return pathname === child.to || pathname.startsWith(child.to + "/");
});
}
export default function Sidebar() { export default function Sidebar() {
const { hasPermission, hasRole } = useAuth(); const { hasPermission, hasRole } = useAuth();
const location = useLocation(); const location = useLocation();
const [openGroup, setOpenGroup] = useState(() => {
// Open the group that contains the current route on initial load
for (const item of navItems) {
if (item.children && isGroupActive(item.children, location.pathname)) {
return item.label;
}
}
return null;
});
const canViewSection = (permission) => { const canViewSection = (permission) => {
if (!permission) return true; if (!permission) return true;
return hasPermission(permission, "view"); return hasPermission(permission, "view");
}; };
// Settings visible only to sysadmin and admin
const canManageStaff = hasRole("sysadmin", "admin"); const canManageStaff = hasRole("sysadmin", "admin");
const canViewDeveloper = hasRole("sysadmin", "admin");
const handleGroupToggle = (label) => {
setOpenGroup((prev) => (prev === label ? null : label));
};
const settingsChildren = [{ to: "/settings/staff", label: "Staff" }];
return ( return (
<aside className="w-56 h-screen flex-shrink-0 p-4 border-r flex flex-col overflow-y-auto" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}> <aside className="w-56 h-screen flex-shrink-0 p-4 border-r flex flex-col overflow-y-auto" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
@@ -78,6 +111,8 @@ export default function Sidebar() {
children={item.children} children={item.children}
currentPath={location.pathname} currentPath={location.pathname}
locked={!hasAccess} locked={!hasAccess}
open={openGroup === item.label}
onToggle={() => handleGroupToggle(item.label)}
/> />
) : ( ) : (
<NavLink <NavLink
@@ -85,7 +120,10 @@ export default function Sidebar() {
to={hasAccess ? item.to : "#"} to={hasAccess ? item.to : "#"}
end={item.to === "/"} end={item.to === "/"}
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)} className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
onClick={(e) => !hasAccess && e.preventDefault()} onClick={(e) => {
if (!hasAccess) { e.preventDefault(); return; }
setOpenGroup(null);
}}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{item.label} {item.label}
@@ -100,16 +138,31 @@ export default function Sidebar() {
})} })}
</nav> </nav>
{/* Developer section */}
{canViewDeveloper && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<nav className="space-y-1">
<NavLink
to="/developer/api"
className={({ isActive }) => linkClass(isActive, false)}
onClick={() => setOpenGroup(null)}
>
API Reference
</NavLink>
</nav>
</div>
)}
{/* Settings section at the bottom */} {/* Settings section at the bottom */}
{canManageStaff && ( {canManageStaff && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}> <div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<nav className="space-y-1"> <nav className="space-y-1">
<CollapsibleGroup <CollapsibleGroup
label="Settings" label="Settings"
children={[ children={settingsChildren}
{ to: "/settings/staff", label: "Staff" },
]}
currentPath={location.pathname} currentPath={location.pathname}
open={openGroup === "Settings"}
onToggle={() => handleGroupToggle("Settings")}
/> />
</nav> </nav>
</div> </div>
@@ -118,25 +171,19 @@ export default function Sidebar() {
); );
} }
function CollapsibleGroup({ label, children, currentPath, locked = false }) { function CollapsibleGroup({ label, children, currentPath, locked = false, open, onToggle }) {
const isChildActive = children.some( const childActive = isGroupActive(children, currentPath);
(child) => const shouldBeOpen = open || childActive;
currentPath === child.to ||
(child.to !== "/" && currentPath.startsWith(child.to + "/"))
);
const [open, setOpen] = useState(isChildActive);
const shouldBeOpen = open || isChildActive;
return ( return (
<div> <div>
<button <button
type="button" type="button"
onClick={() => !locked && setOpen(!shouldBeOpen)} onClick={() => !locked && onToggle()}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${ className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
locked locked
? "opacity-40 cursor-not-allowed" ? "opacity-40 cursor-not-allowed"
: isChildActive : childActive
? "text-[var(--text-heading)]" ? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`} }`}
@@ -145,7 +192,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
{label} {label}
{locked && ( {locked && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg> </svg>
)} )}
</span> </span>
@@ -166,7 +213,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
<NavLink <NavLink
key={child.to} key={child.to}
to={child.to} to={child.to}
end end={child.exact === true}
className={({ isActive }) => className={({ isActive }) =>
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${ `block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
isActive isActive
@@ -183,5 +230,3 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
</div> </div>
); );
} }

View File

@@ -3,13 +3,13 @@ import { useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" }, { value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" }, { value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" }, { value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" }, { value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" }, { value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" }, { value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" }, { value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
]; ];
const BOARD_FAMILY_COLORS = { const BOARD_FAMILY_COLORS = {
@@ -99,12 +99,11 @@ export default function BatchCreator() {
{!result ? ( {!result ? (
<div <div
className="rounded-lg border p-6" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}> <div className="ui-section-card__title-row">
Batch Parameters <h2 className="ui-section-card__title">Batch Parameters</h2>
</h2> </div>
{error && ( {error && (
<div className="text-sm rounded-md p-3 mb-4 border" <div className="text-sm rounded-md p-3 mb-4 border"
@@ -116,7 +115,7 @@ export default function BatchCreator() {
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */} {/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Board Type Board Type
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -147,7 +146,7 @@ export default function BatchCreator() {
{/* Board Revision */} {/* Board Revision */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Board Revision Board Revision
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -169,7 +168,7 @@ export default function BatchCreator() {
{/* Quantity */} {/* Quantity */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Quantity Quantity
</label> </label>
<input <input
@@ -206,8 +205,7 @@ export default function BatchCreator() {
</div> </div>
) : ( ) : (
<div <div
className="rounded-lg border p-6" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div> <div>

View File

@@ -7,13 +7,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos // Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" }, { value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" }, { value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" }, { value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" }, { value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" }, { value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" }, { value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" }, { value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
]; ];
const BOARD_FAMILY_COLORS = { const BOARD_FAMILY_COLORS = {
@@ -589,7 +589,16 @@ export default function DeviceInventory() {
const renderCell = (col, device) => { const renderCell = (col, device) => {
switch (col.id) { switch (col.id) {
case "serial": return <span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>; case "serial": return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>
{/^[A-Z0-9]{10,}$/.test(device.serial_number || "") && (
<span style={{ fontSize: 9, fontWeight: 700, padding: "1px 5px", borderRadius: 3, backgroundColor: "#2e1a00", color: "#fb923c", letterSpacing: "0.04em" }}>
LEGACY
</span>
)}
</span>
);
case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>; case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>;
case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>; case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
case "status": return <StatusBadge status={device.mfg_status} />; case "status": return <StatusBadge status={device.mfg_status} />;

View File

@@ -4,8 +4,8 @@ import { useAuth } from "../auth/AuthContext";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPE_LABELS = { const BOARD_TYPE_LABELS = {
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro", vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
}; };
const STATUS_STYLES = { const STATUS_STYLES = {
@@ -47,9 +47,7 @@ function StatusBadge({ status }) {
function Field({ label, value, mono = false }) { function Field({ label, value, mono = false }) {
return ( return (
<div> <div>
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}> <p className="ui-field-label mb-0.5">{label}</p>
{label}
</p>
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}> <p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value || "—"} {value || "—"}
</p> </p>
@@ -320,11 +318,10 @@ export default function DeviceInventoryDetail() {
)} )}
{/* Identity card */} {/* Identity card */}
<div className="rounded-lg border p-5 mb-4" <div className="ui-section-card mb-4">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <div className="ui-section-card__title-row">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}> <h2 className="ui-section-card__title">Device Identity</h2>
Device Identity </div>
</h2>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Serial Number" value={device?.serial_number} mono /> <Field label="Serial Number" value={device?.serial_number} mono />
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} /> <Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
@@ -339,12 +336,9 @@ export default function DeviceInventoryDetail() {
</div> </div>
{/* Status card */} {/* Status card */}
<div className="rounded-lg border p-5 mb-4" <div className="ui-section-card mb-4">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <div className="ui-section-card__title-row">
<div className="flex items-center justify-between mb-3"> <h2 className="ui-section-card__title">Status</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Status
</h2>
{canEdit && !editingStatus && ( {canEdit && !editingStatus && (
<button <button
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }} onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
@@ -404,11 +398,10 @@ export default function DeviceInventoryDetail() {
</div> </div>
{/* Actions card */} {/* Actions card */}
<div className="rounded-lg border p-5 mb-4" <div className="ui-section-card mb-4">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <div className="ui-section-card__title-row">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}> <h2 className="ui-section-card__title">Actions</h2>
Actions </div>
</h2>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
onClick={downloadNvs} onClick={downloadNvs}
@@ -429,11 +422,10 @@ export default function DeviceInventoryDetail() {
{/* Assign to Customer card */} {/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && ( {canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<div className="rounded-lg border p-5" <div className="ui-section-card">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <div className="ui-section-card__title-row">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}> <h2 className="ui-section-card__title">Assign to Customer</h2>
Assign to Customer </div>
</h2>
{assignSuccess ? ( {assignSuccess ? (
<div className="text-sm rounded-md p-3 border" <div className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}> style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>

View File

@@ -7,13 +7,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos // Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" }, { value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" }, { value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" }, { value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" }, { value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" }, { value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"}, { value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"}, { value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
]; ];
// Color palette per board family (idle → selected → hover glow) // Color palette per board family (idle → selected → hover glow)

View File

@@ -400,10 +400,7 @@ export default function MelodyComposer() {
</div> </div>
)} )}
<section <section className="ui-section-card">
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button> <button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button>
<button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button> <button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button>
@@ -485,10 +482,7 @@ export default function MelodyComposer() {
</div> </div>
</section> </section>
<section <section className="ui-section-card">
className="rounded-lg border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-max border-separate border-spacing-0"> <table className="min-w-max border-separate border-spacing-0">
<thead> <thead>
@@ -633,10 +627,7 @@ export default function MelodyComposer() {
</div> </div>
</section> </section>
<section <section className="ui-section-card">
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div> <div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p> <p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>

View File

@@ -58,9 +58,7 @@ function normalizeFileUrl(url) {
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<div> <div>
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}> <dt className="ui-field-label">{label}</dt>
{label}
</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd> <dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
</div> </div>
); );
@@ -70,9 +68,7 @@ function UrlField({ label, value }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
return ( return (
<div> <div>
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}> <dt className="ui-field-label mb-1">{label}</dt>
{label}
</dt>
<dd className="flex items-center gap-2"> <dd className="flex items-center gap-2">
<span <span
className="text-sm font-mono flex-1 min-w-0" className="text-sm font-mono flex-1 min-w-0"
@@ -354,12 +350,11 @@ export default function MelodyDetail() {
<div className="space-y-6"> <div className="space-y-6">
{/* Melody Information */} {/* Melody Information */}
<section <section
className="rounded-lg p-6 border" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}> <div className="ui-section-card__title-row">
Melody Information <h2 className="ui-section-card__title">Melody Information</h2>
</h2> </div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Color"> <Field label="Color">
{info.color ? ( {info.color ? (
@@ -422,12 +417,11 @@ export default function MelodyDetail() {
{/* Identifiers */} {/* Identifiers */}
<section <section
className="rounded-lg p-6 border" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}> <div className="ui-section-card__title-row">
Identifiers <h2 className="ui-section-card__title">Identifiers</h2>
</h2> </div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field> <Field label="Document ID">{melody.id}</Field>
<Field label="PID (Playback ID)">{melody.pid}</Field> <Field label="PID (Playback ID)">{melody.pid}</Field>
@@ -444,12 +438,11 @@ export default function MelodyDetail() {
<div className="space-y-6"> <div className="space-y-6">
{/* Default Settings */} {/* Default Settings */}
<section <section
className="rounded-lg p-6 border" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}> <div className="ui-section-card__title-row">
Default Settings <h2 className="ui-section-card__title">Default Settings</h2>
</h2> </div>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Speed"> <Field label="Speed">
{settings.speed != null ? ( {settings.speed != null ? (
@@ -474,7 +467,7 @@ export default function MelodyDetail() {
</Field> </Field>
</div> </div>
<div className="col-span-2 md:col-span-3"> <div className="col-span-2 md:col-span-3">
<dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>Note Assignments</dt> <dt className="ui-field-label mb-2">Note Assignments</dt>
<dd> <dd>
{settings.noteAssignments?.length > 0 ? ( {settings.noteAssignments?.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -512,10 +505,11 @@ export default function MelodyDetail() {
{/* Files */} {/* Files */}
<section <section
className="rounded-lg p-6 border" className="ui-section-card"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Files</h2>
</div>
<dl className="space-y-4"> <dl className="space-y-4">
<Field label="Available as Built-In"> <Field label="Available as Built-In">
<label className="inline-flex items-center gap-2"> <label className="inline-flex items-center gap-2">
@@ -681,12 +675,11 @@ export default function MelodyDetail() {
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */} {/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
{builtMelody?.progmem_code && ( {builtMelody?.progmem_code && (
<section <section
className="rounded-lg p-6 border mt-6" className="ui-section-card mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<div className="flex items-center justify-between mb-3"> <div className="ui-section-card__title-row">
<div> <div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2> <h2 className="ui-section-card__title">Firmware Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}> <p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
PROGMEM code for built-in firmware playback &nbsp;·&nbsp; PID: <span className="font-mono">{builtMelody.pid}</span> PROGMEM code for built-in firmware playback &nbsp;·&nbsp; PID: <span className="font-mono">{builtMelody.pid}</span>
</p> </p>
@@ -723,10 +716,11 @@ export default function MelodyDetail() {
{/* Metadata section */} {/* Metadata section */}
{melody.metadata && ( {melody.metadata && (
<section <section
className="rounded-lg p-6 border mt-6" className="ui-section-card mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>History</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">History</h2>
</div>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{melody.metadata.dateCreated && ( {melody.metadata.dateCreated && (
<Field label="Date Created"> <Field label="Date Created">
@@ -750,10 +744,11 @@ export default function MelodyDetail() {
{/* Admin Notes section */} {/* Admin Notes section */}
<section <section
className="rounded-lg p-6 border mt-6" className="ui-section-card mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Admin Notes</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Admin Notes</h2>
</div>
{(melody.metadata?.adminNotes?.length || 0) > 0 ? ( {(melody.metadata?.adminNotes?.length || 0) > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{melody.metadata.adminNotes.map((note, i) => ( {melody.metadata.adminNotes.map((note, i) => (

View File

@@ -46,12 +46,6 @@ const defaultSettings = {
noteAssignments: [], noteAssignments: [],
}; };
// Dark-themed styles
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" }; const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
@@ -408,7 +402,7 @@ export default function MelodyForm() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={headingStyle}> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Melody" : "Add Melody"} {isEdit ? "Edit Melody" : "Add Melody"}
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -533,13 +527,15 @@ export default function MelodyForm() {
{/* ===== Left Column ===== */} {/* ===== Left Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Melody Info Section --- */} {/* --- Melody Info Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Melody Information</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name (localized) */} {/* Name (localized) */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Name *</label> <label className="ui-form-label">Name *</label>
<button <button
type="button" type="button"
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })} onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
@@ -561,7 +557,7 @@ export default function MelodyForm() {
{/* Description (localized) */} {/* Description (localized) */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Description</label> <label className="ui-form-label">Description</label>
<button <button
type="button" type="button"
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })} onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
@@ -576,31 +572,31 @@ export default function MelodyForm() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Melody Tone</label> <label className="ui-form-label">Melody Tone</label>
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}> <select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))} {MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Type</label> <label className="ui-form-label">Type</label>
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}> <select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))} {MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Archetype Notes</label> <label className="ui-form-label">Total Archetype Notes</label>
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} /> <input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Steps</label> <label className="ui-form-label">Steps</label>
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} /> <input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label> <label className="ui-form-label">Min Speed</label>
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} /> <input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.minSpeed > 0 && ( {information.minSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p> <p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
@@ -608,7 +604,7 @@ export default function MelodyForm() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label> <label className="ui-form-label">Max Speed</label>
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} /> <input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.maxSpeed > 0 && ( {information.maxSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p> <p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
@@ -617,7 +613,7 @@ export default function MelodyForm() {
{/* Color */} {/* Color */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Color</label> <label className="ui-form-label">Color</label>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} /> <span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" /> <input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
@@ -645,7 +641,7 @@ export default function MelodyForm() {
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Custom Tags</label> <label className="ui-form-label">Custom Tags</label>
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" /> <input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button> <button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
@@ -665,16 +661,18 @@ export default function MelodyForm() {
</section> </section>
{/* --- Identifiers Section --- */} {/* --- Identifiers Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Identifiers</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label> <label className="ui-form-label">PID (Playback ID)</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} /> <input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
</div> </div>
{url && ( {url && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label> <label className="ui-form-label">URL (auto-set from binary upload)</label>
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} /> <input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
</div> </div>
)} )}
@@ -685,11 +683,13 @@ export default function MelodyForm() {
{/* ===== Right Column ===== */} {/* ===== Right Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Default Settings Section --- */} {/* --- Default Settings Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Default Settings</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Speed</label> <label className="ui-form-label">Speed</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" /> <input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span> <span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
@@ -700,7 +700,7 @@ export default function MelodyForm() {
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Duration</label> <label className="ui-form-label">Duration</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" /> <input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span> <span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
@@ -709,12 +709,12 @@ export default function MelodyForm() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Run Duration</label> <label className="ui-form-label">Total Run Duration</label>
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} /> <input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Pause Duration</label> <label className="ui-form-label">Pause Duration</label>
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} /> <input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div> </div>
@@ -724,13 +724,13 @@ export default function MelodyForm() {
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>Echo Ring (comma-separated integers)</label> <label className="ui-form-label">Echo Ring (comma-separated integers)</label>
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} /> <input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium" style={labelStyle}>Note Assignments</label> <label className="ui-form-label">Note Assignments</label>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}> <span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""} {computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
</span> </span>
@@ -752,8 +752,10 @@ export default function MelodyForm() {
</section> </section>
{/* --- File Upload Section --- */} {/* --- File Upload Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Files</h2>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -768,7 +770,7 @@ export default function MelodyForm() {
</label> </label>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label> <label className="ui-form-label">Binary File (.bsm)</label>
{(() => { {(() => {
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary(); const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
const missingArchetype = Boolean(pid) && !builtMelody?.id; const missingArchetype = Boolean(pid) && !builtMelody?.id;
@@ -867,7 +869,7 @@ export default function MelodyForm() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label> <label className="ui-form-label">Audio Preview (.mp3)</label>
{normalizeFileUrl(existingFiles.preview_url) ? ( {normalizeFileUrl(existingFiles.preview_url) ? (
<div className="mb-2 space-y-1"> <div className="mb-2 space-y-1">
{(() => { {(() => {
@@ -916,8 +918,10 @@ export default function MelodyForm() {
</div> </div>
{/* --- Admin Notes Section --- */} {/* --- Admin Notes Section --- */}
<section className="rounded-lg p-6 border mt-6" style={sectionStyle}> <section className="ui-section-card mt-6">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Admin Notes</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Admin Notes</h2>
</div>
<div className="space-y-3"> <div className="space-y-3">
{adminNotes.map((note, i) => ( {adminNotes.map((note, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}> <div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>

View File

@@ -14,12 +14,7 @@ const DEFAULT_NOTE_ASSIGNMENT_COLORS = [
"#F87171", "#EF4444", "#DC2626", "#B91C1C", "#F87171", "#EF4444", "#DC2626", "#B91C1C",
]; ];
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" }; const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
export default function MelodySettings() { export default function MelodySettings() {
@@ -200,8 +195,10 @@ export default function MelodySettings() {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* --- Languages Section --- */} {/* --- Languages Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Available Languages</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Available Languages</h2>
</div>
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{settings.available_languages.map((code) => ( {settings.available_languages.map((code) => (
<div <div
@@ -237,8 +234,10 @@ export default function MelodySettings() {
</section> </section>
{/* --- Quick Colors Section --- */} {/* --- Quick Colors Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Quick Selection Colors</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Quick Selection Colors</h2>
</div>
<div className="flex flex-wrap gap-3 mb-4"> <div className="flex flex-wrap gap-3 mb-4">
{settings.quick_colors.map((color) => ( {settings.quick_colors.map((color) => (
<div key={color} className="relative group"> <div key={color} className="relative group">
@@ -279,8 +278,10 @@ export default function MelodySettings() {
</section> </section>
{/* --- Duration Presets Section --- */} {/* --- Duration Presets Section --- */}
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}> <section className="ui-section-card xl:col-span-2">
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Duration Presets (seconds)</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Duration Presets (seconds)</h2>
</div>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
{settings.duration_values.map((val) => ( {settings.duration_values.map((val) => (
<div <div
@@ -317,8 +318,10 @@ export default function MelodySettings() {
</section> </section>
{/* --- Note Assignment Colors --- */} {/* --- Note Assignment Colors --- */}
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}> <section className="ui-section-card xl:col-span-2">
<h2 className="text-lg font-semibold mb-2" style={headingStyle}>Note Assignment Color Coding</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__header-title">Note Assignment Color Coding</h2>
</div>
<p className="text-xs mb-4" style={mutedStyle}> <p className="text-xs mb-4" style={mutedStyle}>
Colors used in Composer, Playback, and View table dots. Click a bell to customize. Colors used in Composer, Playback, and View table dots. Click a bell to customize.
</p> </p>

View File

@@ -3,8 +3,6 @@ import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client"; import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog"; import ConfirmDialog from "../../components/ConfirmDialog";
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
@@ -262,21 +260,23 @@ export default function ArchetypeForm() {
)} )}
<div className="space-y-6"> <div className="space-y-6">
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Archetype Info</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Archetype Info</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label> <label className="ui-form-label">Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} /> <input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label> <label className="ui-form-label">PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} /> <input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p> <p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label> <label className="ui-form-label">Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span> <span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div> </div>
<textarea <textarea
@@ -295,8 +295,10 @@ export default function ArchetypeForm() {
</section> </section>
{isEdit && ( {isEdit && (
<section className="rounded-lg p-6 border" style={sectionStyle}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2> <div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build</h2>
</div>
<p className="text-sm mb-4" style={mutedStyle}> <p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output. Save any changes above before building. Rebuilding will overwrite previous output.
{hasUnsavedChanges && ( {hasUnsavedChanges && (
@@ -402,7 +404,7 @@ export default function ArchetypeForm() {
)} )}
{!isEdit && ( {!isEdit && (
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}> <div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving. Build actions (Binary + PROGMEM Code) will be available after saving.
</div> </div>
)} )}

View File

@@ -6,20 +6,11 @@ import ConfirmDialog from "../components/ConfirmDialog";
const ROLE_COLORS = { const ROLE_COLORS = {
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" }, sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
admin: { bg: "#3b2a0a", text: "#f6ad55" }, admin: { bg: "#3b2a0a", text: "#f6ad55" },
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" }, editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" }, user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
}; };
const SECTIONS = [
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Issues and Notes" },
];
const ACTIONS = ["view", "add", "edit", "delete"];
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<div> <div>
@@ -29,6 +20,56 @@ function Field({ label, children }) {
); );
} }
/** Yes/No badge */
function PBadge({ value }) {
return value ? (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
);
}
/** Access level badge for segmented permissions */
function LevelBadge({ viewVal, editVal }) {
if (editVal) return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>Edit</span>;
if (viewVal) return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>View Only</span>;
return <span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No Access</span>;
}
/** A row in the permission display */
function PermRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
<PBadge value={value} />
</div>
);
}
/** A segmented row in the permission display */
function SegmentedRow({ label, viewVal, editVal }) {
return (
<div className="flex items-center justify-between py-1.5" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
<LevelBadge viewVal={viewVal} editVal={editVal} />
</div>
);
}
/** Section card for permissions display */
function PermSection({ title, children }) {
return (
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">{title}</span>
</h2>
<div className="pt-2">
{children}
</div>
</section>
);
}
export default function StaffDetail() { export default function StaffDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -101,6 +142,21 @@ export default function StaffDetail() {
const canDelete = canEdit && member.id !== user?.sub; const canDelete = canEdit && member.id !== user?.sub;
const roleColors = ROLE_COLORS[member.role] || ROLE_COLORS.user; const roleColors = ROLE_COLORS[member.role] || ROLE_COLORS.user;
const showPerms = (member.role === "editor" || member.role === "user") && member.permissions;
const q = member.permissions || {};
const mel = q.melodies || {};
const dev = q.devices || {};
const usr = q.app_users || {};
const iss = q.issues_notes || {};
const mail = q.mail || {};
const crm = q.crm || {};
const cc = q.crm_customers || {};
const cprod = q.crm_products || {};
const mfg = q.mfg || {};
const apir = q.api_reference || {};
const mqtt = q.mqtt || {};
return ( return (
<div> <div>
{/* Header */} {/* Header */}
@@ -116,11 +172,9 @@ export default function StaffDetail() {
</span> </span>
<span <span
className="px-2 py-0.5 text-xs rounded-full" className="px-2 py-0.5 text-xs rounded-full"
style={ style={member.is_active
member.is_active ? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" } : { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
> >
{member.is_active ? "Active" : "Inactive"} {member.is_active ? "Active" : "Inactive"}
</span> </span>
@@ -143,97 +197,161 @@ export default function StaffDetail() {
)} )}
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> {/* Account Info — full width */}
{/* Account Info */} <section className="ui-section-card mb-6">
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <h2 className="ui-section-card__header-title mb-4">Account Information</h2>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2> <dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
<dl className="grid grid-cols-2 gap-4"> <Field label="Name">{member.name}</Field>
<Field label="Name">{member.name}</Field> <Field label="Email">{member.email}</Field>
<Field label="Email">{member.email}</Field> <Field label="Role">
<Field label="Role"> <span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}> {member.role}
{member.role} </span>
</span> </Field>
</Field> <Field label="Status">
<Field label="Status"> <span
<span className="px-2 py-0.5 text-xs rounded-full"
className="px-2 py-0.5 text-xs rounded-full" style={member.is_active
style={ ? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
member.is_active : { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" } >
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" } {member.is_active ? "Active" : "Inactive"}
} </span>
> </Field>
{member.is_active ? "Active" : "Inactive"} <Field label="Document ID">
</span> <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span>
</Field> </Field>
<Field label="Document ID"> </dl>
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span> </section>
</Field>
</dl> {/* Admin / SysAdmin notice */}
{(member.role === "sysadmin" || member.role === "admin") && (
<section className="ui-section-card">
<h2 className="ui-section-card__header-title mb-3">Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{member.role === "sysadmin"
? "SysAdmin has full god-mode access to all features and settings."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."}
</p>
</section> </section>
)}
{/* Permissions */} {/* Permission sections */}
{(member.role === "editor" || member.role === "user") && member.permissions && ( {showPerms && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="overflow-x-auto"> {/* Melodies */}
<table className="w-full text-sm"> <PermSection title="Melodies">
<thead> <PermRow label="View" value={mel.view} />
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}> <PermRow label="Add" value={mel.add} />
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th> <PermRow label="Delete" value={mel.delete} />
{ACTIONS.map((a) => ( <PermRow label="Safe Edit" value={mel.safe_edit || mel.full_edit} />
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th> <PermRow label="Full Edit" value={mel.full_edit} />
))} <PermRow label="Archetype Access" value={mel.archetype_access} />
</tr> <PermRow label="Settings Access" value={mel.settings_access} />
</thead> <PermRow label="Compose Access" value={mel.compose_access} />
<tbody> </PermSection>
{SECTIONS.map((sec) => {
const sp = member.permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-2 text-center">
{sp[a] ? (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}> {/* Devices */}
<div className="flex items-center gap-3"> <PermSection title="Devices">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access:</span> <PermRow label="View" value={dev.view} />
{member.permissions.mqtt ? ( <PermRow label="Add" value={dev.add} />
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Enabled</span> <PermRow label="Delete" value={dev.delete} />
) : ( <PermRow label="Safe Edit (Info)" value={dev.safe_edit || dev.full_edit} />
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>Disabled</span> <PermRow label="Edit Bells" value={dev.edit_bells || dev.full_edit} />
)} <PermRow label="Edit Clock & Alerts" value={dev.edit_clock || dev.full_edit} />
<PermRow label="Edit Warranty/Sub" value={dev.edit_warranty || dev.full_edit} />
<PermRow label="Full Edit" value={dev.full_edit} />
<PermRow label="Control" value={dev.control} />
</PermSection>
{/* App Users */}
<PermSection title="App Users">
<PermRow label="View" value={usr.view} />
<PermRow label="Add" value={usr.add} />
<PermRow label="Delete" value={usr.delete} />
<PermRow label="Safe Edit" value={usr.safe_edit || usr.full_edit} />
<PermRow label="Full Edit" value={usr.full_edit} />
</PermSection>
{/* Issues & Notes */}
<PermSection title="Issues & Notes">
<PermRow label="View" value={iss.view} />
<PermRow label="Add" value={iss.add} />
<PermRow label="Delete" value={iss.delete} />
<PermRow label="Edit" value={iss.edit} />
</PermSection>
{/* Mail */}
<PermSection title="Mail">
<PermRow label="View Inbox" value={mail.view} />
<PermRow label="Compose" value={mail.compose} />
<PermRow label="Reply" value={mail.reply} />
</PermSection>
{/* CRM */}
<PermSection title="CRM">
<PermRow label="View Activity Log" value={crm.activity_log} />
</PermSection>
{/* CRM Products */}
<PermSection title="CRM Products">
<PermRow label="View" value={cprod.view} />
<PermRow label="Add" value={cprod.add} />
<PermRow label="Edit/Delete" value={cprod.edit} />
</PermSection>
{/* Manufacturing */}
<PermSection title="Manufacturing">
<PermRow label="View Inventory" value={mfg.view_inventory} />
<PermRow label="Edit" value={mfg.edit} />
<PermRow label="Provision Device" value={mfg.provision} />
<SegmentedRow label="Firmware" viewVal={mfg.firmware_view} editVal={mfg.firmware_edit} />
</PermSection>
</div>
{/* CRM Customers — full width */}
<section className="ui-section-card mt-6">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">CRM Customers</span>
{cc.full_access && (
<span className="text-xs px-2 py-0.5 rounded ml-3" style={{ backgroundColor: "rgba(116,184,22,0.15)", color: "var(--accent)" }}>
Full Access
</span>
)}
</h2>
<div className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div>
<PermRow label="Overview Tab" value={cc.full_access || cc.overview} />
<PermRow label="Add Customer" value={cc.full_access || cc.add} />
<PermRow label="Delete Customer" value={cc.full_access || cc.delete} />
<SegmentedRow label="Orders" viewVal={cc.full_access || cc.orders_view} editVal={cc.full_access || cc.orders_edit} />
<SegmentedRow label="Quotations" viewVal={cc.full_access || cc.quotations_view} editVal={cc.full_access || cc.quotations_edit} />
</div>
<div>
<SegmentedRow label="Files & Media" viewVal={cc.full_access || cc.files_view} editVal={cc.full_access || cc.files_edit} />
<SegmentedRow label="Devices Tab" viewVal={cc.full_access || cc.devices_view} editVal={cc.full_access || cc.devices_edit} />
<PermRow label="Comms: View" value={cc.full_access || cc.comms_view} />
<PermRow label="Comms: Log Entry" value={cc.full_access || cc.comms_log} />
<PermRow label="Comms: Edit Entries" value={cc.full_access || cc.comms_edit} />
<PermRow label="Comms: Compose & Send" value={cc.full_access || cc.comms_compose} />
</div> </div>
</div> </div>
</section> </section>
)}
{(member.role === "sysadmin" || member.role === "admin") && ( {/* API Reference + MQTT */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2> <PermSection title="API Reference">
<p className="text-sm" style={{ color: "var(--text-muted)" }}> <PermRow label="Access API Reference" value={apir.access} />
{member.role === "sysadmin" </PermSection>
? "SysAdmin has full access to all features and settings." <PermSection title="MQTT">
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."} <PermRow label="MQTT Access" value={mqtt.access} />
</p> </PermSection>
</section> </div>
)} </>
</div> )}
{/* Reset Password Dialog */} {/* Reset Password Dialog */}
{showResetPw && ( {showResetPw && (
@@ -249,7 +367,7 @@ export default function StaffDetail() {
className="w-full px-3 py-2 rounded-md text-sm border mb-3" className="w-full px-3 py-2 rounded-md text-sm border mb-3"
style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }} style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
/> />
{pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>} {pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>}
{pwSuccess && <p className="text-xs mb-2" style={{ color: "var(--success-text)" }}>{pwSuccess}</p>} {pwSuccess && <p className="text-xs mb-2" style={{ color: "var(--success-text)" }}>{pwSuccess}</p>}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button

View File

@@ -3,123 +3,278 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
const SECTIONS = [ // ─── Default permission sets ───────────────────────────────────────────────
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Issues and Notes" },
];
const ACTIONS = ["view", "add", "edit", "delete"]; const EDITOR_PERMS = {
melodies: { view: true, add: true, delete: true, safe_edit: true, full_edit: true, archetype_access: true, settings_access: true, compose_access: true },
const DEFAULT_PERMS_EDITOR = { devices: { view: true, add: true, delete: true, safe_edit: true, edit_bells: true, edit_clock: true, edit_warranty: true, full_edit: true, control: true },
melodies: { view: true, add: true, edit: true, delete: true }, app_users: { view: true, add: true, delete: true, safe_edit: true, full_edit: true },
devices: { view: true, add: true, edit: true, delete: true }, issues_notes: { view: true, add: true, delete: true, edit: true },
app_users: { view: true, add: true, edit: true, delete: true }, mail: { view: true, compose: true, reply: true },
equipment: { view: true, add: true, edit: true, delete: true }, crm: { activity_log: true },
mqtt: true, crm_customers: { full_access: true, overview: true, orders_view: true, orders_edit: true, quotations_view: true, quotations_edit: true, comms_view: true, comms_log: true, comms_edit: true, comms_compose: true, add: true, delete: true, files_view: true, files_edit: true, devices_view: true, devices_edit: true },
crm_products: { view: true, add: true, edit: true },
mfg: { view_inventory: true, edit: true, provision: true, firmware_view: true, firmware_edit: true },
api_reference: { access: true },
mqtt: { access: true },
}; };
const DEFAULT_PERMS_USER = { const USER_PERMS = {
melodies: { view: true, add: false, edit: false, delete: false }, melodies: { view: true, add: false, delete: false, safe_edit: false, full_edit: false, archetype_access: false, settings_access: false, compose_access: false },
devices: { view: true, add: false, edit: false, delete: false }, devices: { view: true, add: false, delete: false, safe_edit: false, edit_bells: false, edit_clock: false, edit_warranty: false, full_edit: false, control: false },
app_users: { view: true, add: false, edit: false, delete: false }, app_users: { view: true, add: false, delete: false, safe_edit: false, full_edit: false },
equipment: { view: true, add: false, edit: false, delete: false }, issues_notes: { view: true, add: false, delete: false, edit: false },
mqtt: false, mail: { view: true, compose: false, reply: false },
crm: { activity_log: false },
crm_customers: { full_access: false, overview: true, orders_view: true, orders_edit: false, quotations_view: true, quotations_edit: false, comms_view: true, comms_log: false, comms_edit: false, comms_compose: false, add: false, delete: false, files_view: true, files_edit: false, devices_view: true, devices_edit: false },
crm_products: { view: true, add: false, edit: false },
mfg: { view_inventory: true, edit: false, provision: false, firmware_view: true, firmware_edit: false },
api_reference: { access: false },
mqtt: { access: false },
}; };
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// ─── Dependency rules ──────────────────────────────────────────────────────
function applyDependencies(section, key, value, prev) {
const s = { ...prev[section] };
s[key] = value;
if (value) {
const VIEW_FORCING = ["add", "delete", "safe_edit", "full_edit", "edit_bells", "edit_clock", "edit_warranty", "control", "edit"];
if (VIEW_FORCING.includes(key) && "view" in s) s.view = true;
if (section === "melodies" && key === "full_edit") s.safe_edit = true;
if (section === "devices" && key === "full_edit") { s.safe_edit = true; s.edit_bells = true; s.edit_clock = true; s.edit_warranty = true; s.view = true; }
if (section === "app_users" && key === "full_edit") s.safe_edit = true;
if (section === "crm_customers") {
if (key === "full_access") Object.keys(s).forEach((k) => { s[k] = true; });
if (key === "orders_edit") s.orders_view = true;
if (key === "quotations_edit") s.quotations_view = true;
if (key === "files_edit") s.files_view = true;
if (key === "devices_edit") s.devices_view = true;
if (["comms_log", "comms_edit", "comms_compose"].includes(key)) s.comms_view = true;
}
if (section === "mfg" && key === "firmware_edit") s.firmware_view = true;
if (section === "mfg" && key === "edit") s.view_inventory = true;
if (section === "mfg" && key === "provision") s.view_inventory = true;
}
return { ...prev, [section]: s };
}
// ─── Pill button colors ────────────────────────────────────────────────────
// Disabled / No Access → danger red
// View (middle) → badge blue
// Enabled / Edit → accent green
const PILL_STYLES = {
off: {
active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
view: {
active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
on: {
active: { bg: "var(--success-bg)", border: "var(--accent)", color: "var(--success-text)", fontWeight: 600 },
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
},
};
function pillStyle(tone, isActive) {
const s = PILL_STYLES[tone][isActive ? "active" : "inactive"];
return { backgroundColor: s.bg, borderColor: s.border, color: s.color, fontWeight: s.fontWeight };
}
// ─── Sub-components ────────────────────────────────────────────────────────
/**
* A permission row: label/description on the left, dual pill buttons on the right.
* Disabled / Enabled (red / green)
*/
function PermRow({ label, description, value, onChange, disabled }) {
return (
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<div className="min-w-0">
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
{description && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
)}
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => onChange(false)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", !value), borderRight: "1px solid var(--border-primary)" }}
>
Disabled
</button>
<button
type="button"
onClick={() => onChange(true)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", !!value)}
>
Enabled
</button>
</div>
</div>
);
}
/**
* 3-state row: No Access (red) | View (blue) | Edit (green)
* value: "none" | "view" | "edit"
*/
function SegmentedRow({ label, description, value, onChange, disabled }) {
return (
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<div className="min-w-0">
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
{description && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
)}
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => onChange("none")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", value === "none"), borderRight: "1px solid var(--border-primary)" }}
>
No Access
</button>
<button
type="button"
onClick={() => onChange("view")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("view", value === "view"), borderRight: "1px solid var(--border-primary)" }}
>
View
</button>
<button
type="button"
onClick={() => onChange("edit")}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", value === "edit")}
>
Edit
</button>
</div>
</div>
);
}
/** Section card wrapper */
function PermSection({ title, children }) {
return (
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">{title}</span>
</h2>
<div className="pt-1">
{children}
</div>
</section>
);
}
// ─── Main Component ────────────────────────────────────────────────────────
export default function StaffForm() { export default function StaffForm() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isEdit = !!id; const isEdit = !!id;
const [form, setForm] = useState({ const [form, setForm] = useState({ name: "", email: "", password: "", role: "user", is_active: true });
name: "", const [permissions, setPermissions] = useState(deepClone(USER_PERMS));
email: "",
password: "",
role: "user",
is_active: true,
});
const [permissions, setPermissions] = useState({ ...DEFAULT_PERMS_USER });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
if (isEdit) { if (!isEdit) return;
setLoading(true); setLoading(true);
api.get(`/staff/${id}`) api.get(`/staff/${id}`)
.then((data) => { .then((data) => {
setForm({ setForm({ name: data.name, email: data.email, password: "", role: data.role, is_active: data.is_active });
name: data.name, if (data.permissions) {
email: data.email, const base = data.role === "editor" ? deepClone(EDITOR_PERMS) : deepClone(USER_PERMS);
password: "", const merged = {};
role: data.role, Object.keys(base).forEach((sec) => { merged[sec] = { ...base[sec], ...(data.permissions[sec] || {}) }; });
is_active: data.is_active, setPermissions(merged);
}); } else if (data.role === "editor") {
if (data.permissions) { setPermissions(deepClone(EDITOR_PERMS));
setPermissions(data.permissions); } else {
} else if (data.role === "editor") { setPermissions(deepClone(USER_PERMS));
setPermissions({ ...DEFAULT_PERMS_EDITOR }); }
} else if (data.role === "user") { })
setPermissions({ ...DEFAULT_PERMS_USER }); .catch((err) => setError(err.message))
} .finally(() => setLoading(false));
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}
}, [id]); }, [id]);
const handleRoleChange = (newRole) => { const handleRoleChange = (newRole) => {
setForm((f) => ({ ...f, role: newRole })); setForm((f) => ({ ...f, role: newRole }));
if (newRole === "editor") { if (newRole === "editor") setPermissions(deepClone(EDITOR_PERMS));
setPermissions({ ...DEFAULT_PERMS_EDITOR }); else if (newRole === "user") setPermissions(deepClone(USER_PERMS));
} else if (newRole === "user") {
setPermissions({ ...DEFAULT_PERMS_USER });
}
}; };
const togglePermission = (section, action) => { const setPerm = (section, key, value) =>
setPermissions((prev) => ({ setPermissions((prev) => applyDependencies(section, key, value, prev));
...prev,
[section]: { const setSegmented = (section, viewKey, editKey, val) => {
...prev[section], setPermissions((prev) => {
[action]: !prev[section][action], const next = { ...prev, [section]: { ...prev[section] } };
}, next[section][viewKey] = val !== "none";
})); next[section][editKey] = val === "edit";
return next;
});
}; };
const toggleMqtt = () => { const segVal = (section, viewKey, editKey) => {
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt })); const s = permissions[section] || {};
if (s[editKey]) return "edit";
if (s[viewKey]) return "view";
return "none";
}; };
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
setSaving(true); setSaving(true);
try { try {
const body = { const body = { name: form.name, email: form.email, role: form.role };
name: form.name,
email: form.email,
role: form.role,
};
if (isEdit) { if (isEdit) {
body.is_active = form.is_active; body.is_active = form.is_active;
if (form.role === "editor" || form.role === "user") { body.permissions = (form.role === "editor" || form.role === "user") ? permissions : null;
body.permissions = permissions;
} else {
body.permissions = null;
}
await api.put(`/staff/${id}`, body); await api.put(`/staff/${id}`, body);
navigate(`/settings/staff/${id}`); navigate(`/settings/staff/${id}`);
} else { } else {
body.password = form.password; body.password = form.password;
if (form.role === "editor" || form.role === "user") { if (form.role === "editor" || form.role === "user") body.permissions = permissions;
body.permissions = permissions;
}
const result = await api.post("/staff", body); const result = await api.post("/staff", body);
navigate(`/settings/staff/${result.id}`); navigate(`/settings/staff/${result.id}`);
} }
@@ -134,18 +289,36 @@ export default function StaffForm() {
const roleOptions = user?.role === "sysadmin" const roleOptions = user?.role === "sysadmin"
? ["sysadmin", "admin", "editor", "user"] ? ["sysadmin", "admin", "editor", "user"]
: ["editor", "user"]; // Admin can only create editor/user : ["editor", "user"];
const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }; const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" };
const showPerms = form.role === "editor" || form.role === "user";
const mel = permissions.melodies || {};
const dev = permissions.devices || {};
const usr = permissions.app_users || {};
const iss = permissions.issues_notes || {};
const mail = permissions.mail || {};
const crm = permissions.crm || {};
const cc = permissions.crm_customers || {};
const cprod = permissions.crm_products || {};
const mfg = permissions.mfg || {};
const apir = permissions.api_reference || {};
const mqtt = permissions.mqtt || {};
const ccLocked = !!cc.full_access;
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-6">
<button onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}> <button onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; {isEdit ? "Back to Staff Member" : "Back to Staff"} &larr; {isEdit ? "Back to Staff Member" : "Back to Staff"}
</button> </button>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{isEdit ? "Edit Staff Member" : "Add Staff Member"}</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Staff Member" : "Add Staff Member"}
</h1>
</div> </div>
{error && ( {error && (
@@ -154,56 +327,29 @@ export default function StaffForm() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> {/* ── Account Information ── */}
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2> <section className="ui-section-card">
<h2 className="ui-section-card__header-title mb-4">Account Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label> <label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label>
<input <input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required className={inputClass} style={inputStyle} />
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
</div> </div>
<div> <div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label> <label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label>
<input <input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required className={inputClass} style={inputStyle} />
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
</div> </div>
{!isEdit && ( {!isEdit && (
<div> <div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label> <label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label>
<input <input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} required minLength={6} className={inputClass} style={inputStyle} placeholder="Min 6 characters" />
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required
minLength={6}
className={inputClass}
style={inputStyle}
placeholder="Min 6 characters"
/>
</div> </div>
)} )}
<div> <div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label> <label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label>
<select <select value={form.role} onChange={(e) => handleRoleChange(e.target.value)} className={`${inputClass} cursor-pointer`} style={inputStyle}>
value={form.role}
onChange={(e) => handleRoleChange(e.target.value)}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
{roleOptions.map((r) => ( {roleOptions.map((r) => (
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option> <option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
))} ))}
@@ -212,12 +358,7 @@ export default function StaffForm() {
{isEdit && ( {isEdit && (
<div> <div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label> <label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label>
<select <select value={form.is_active ? "active" : "inactive"} onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))} className={`${inputClass} cursor-pointer`} style={inputStyle}>
value={form.is_active ? "active" : "inactive"}
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
<option value="active">Active</option> <option value="active">Active</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
@@ -226,77 +367,234 @@ export default function StaffForm() {
</div> </div>
</section> </section>
{/* Permissions Matrix - only for editor/user */} {/* ── Admin / SysAdmin notice ── */}
{(form.role === "editor" || form.role === "user") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
Configure which sections and actions this staff member can access.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
{ACTIONS.map((a) => (
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{SECTIONS.map((sec) => {
const sp = permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-3 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-3 text-center">
<input
type="checkbox"
checked={!!sp[a]}
onChange={() => togglePermission(sec.key, a)}
className="h-4 w-4 rounded cursor-pointer"
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!permissions.mqtt}
onChange={toggleMqtt}
className="h-4 w-4 rounded cursor-pointer"
/>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
(Dashboard, Commands, Logs, WebSocket)
</span>
</label>
</div>
</section>
)}
{(form.role === "sysadmin" || form.role === "admin") && ( {(form.role === "sysadmin" || form.role === "admin") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}> <section className="ui-section-card">
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2> <h2 className="ui-section-card__header-title mb-3">Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}> <p className="text-sm" style={{ color: "var(--text-muted)" }}>
{form.role === "sysadmin" {form.role === "sysadmin"
? "SysAdmin has full access to all features and settings. No permission customization needed." ? "SysAdmin has full god-mode access to all features and settings. No permission customization needed."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings. No permission customization needed."} : "Admin has full access to all features, except managing SysAdmin accounts and system-level settings. No permission customization needed."}
</p> </p>
</section> </section>
)} )}
{/* Submit */} {/* ── Permission Sections ── */}
<div className="flex gap-3"> {showPerms && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Melodies */}
<PermSection title="Melodies">
<PermRow label="View" value={mel.view} onChange={(v) => setPerm("melodies", "view", v)} />
<PermRow label="Add" value={mel.add} onChange={(v) => setPerm("melodies", "add", v)} />
<PermRow label="Delete" value={mel.delete} onChange={(v) => setPerm("melodies", "delete", v)} />
<PermRow label="Safe Edit" value={mel.safe_edit} onChange={(v) => setPerm("melodies", "safe_edit", v)} description="Name, Description, Tone, Type, Steps, Colour, Tags" disabled={mel.full_edit} />
<PermRow label="Full Edit" value={mel.full_edit} onChange={(v) => setPerm("melodies", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
<PermRow label="Archetype Access" value={mel.archetype_access} onChange={(v) => setPerm("melodies", "archetype_access", v)} description="Access the Archetype Editor" />
<PermRow label="Settings Access" value={mel.settings_access} onChange={(v) => setPerm("melodies", "settings_access", v)} description="View & change global melody settings" />
<PermRow label="Compose Access" value={mel.compose_access} onChange={(v) => setPerm("melodies", "compose_access", v)} description="Use the Composer to create melodies" />
</PermSection>
{/* Devices */}
<PermSection title="Devices">
<PermRow label="View" value={dev.view} onChange={(v) => setPerm("devices", "view", v)} />
<PermRow label="Add" value={dev.add} onChange={(v) => setPerm("devices", "add", v)} />
<PermRow label="Delete" value={dev.delete} onChange={(v) => setPerm("devices", "delete", v)} />
<PermRow label="Safe Edit" value={dev.safe_edit} onChange={(v) => setPerm("devices", "safe_edit", v)} description="Edit General Info tab only" disabled={dev.full_edit} />
<PermRow label="Edit Bells" value={dev.edit_bells} onChange={(v) => setPerm("devices", "edit_bells", v)} description="Bell Mechanisms tab" disabled={dev.full_edit} />
<PermRow label="Edit Clock & Alerts" value={dev.edit_clock} onChange={(v) => setPerm("devices", "edit_clock", v)} description="Clock & Alerts tab" disabled={dev.full_edit} />
<PermRow label="Edit Warranty / Sub" value={dev.edit_warranty} onChange={(v) => setPerm("devices", "edit_warranty", v)} description="Warranty and Subscription tab" disabled={dev.full_edit} />
<PermRow label="Full Edit" value={dev.full_edit} onChange={(v) => setPerm("devices", "full_edit", v)} description="Enables all edit options above" />
<PermRow label="Control" value={dev.control} onChange={(v) => setPerm("devices", "control", v)} description="Send commands via the Control tab" />
</PermSection>
{/* App Users */}
<PermSection title="App Users">
<PermRow label="View" value={usr.view} onChange={(v) => setPerm("app_users", "view", v)} />
<PermRow label="Add" value={usr.add} onChange={(v) => setPerm("app_users", "add", v)} />
<PermRow label="Delete" value={usr.delete} onChange={(v) => setPerm("app_users", "delete", v)} />
<PermRow label="Safe Edit" value={usr.safe_edit} onChange={(v) => setPerm("app_users", "safe_edit", v)} description="Photo, Name, Email, Phone, Title only" disabled={usr.full_edit} />
<PermRow label="Full Edit" value={usr.full_edit} onChange={(v) => setPerm("app_users", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
</PermSection>
{/* Issues & Notes */}
<PermSection title="Issues & Notes">
<p className="text-xs pb-2 pt-1" style={{ color: "var(--text-muted)", borderBottom: "1px solid var(--border-secondary)" }}>
These permissions also apply to Notes linked from Device and User pages.
</p>
<PermRow label="View" value={iss.view} onChange={(v) => setPerm("issues_notes", "view", v)} />
<PermRow label="Add" value={iss.add} onChange={(v) => setPerm("issues_notes", "add", v)} />
<PermRow label="Delete" value={iss.delete} onChange={(v) => setPerm("issues_notes", "delete", v)} />
<PermRow label="Edit" value={iss.edit} onChange={(v) => setPerm("issues_notes", "edit", v)} />
</PermSection>
{/* Mail */}
<PermSection title="Mail">
<PermRow label="View Inbox" value={mail.view} onChange={(v) => setPerm("mail", "view", v)} />
<PermRow label="Compose" value={mail.compose} onChange={(v) => setPerm("mail", "compose", v)} description="Send new emails" />
<PermRow label="Reply" value={mail.reply} onChange={(v) => setPerm("mail", "reply", v)} description="Reply to existing emails" />
</PermSection>
{/* CRM */}
<PermSection title="CRM">
<PermRow label="View Activity Log" value={crm.activity_log} onChange={(v) => setPerm("crm", "activity_log", v)} />
</PermSection>
{/* CRM Products */}
<PermSection title="CRM Products">
<PermRow label="View" value={cprod.view} onChange={(v) => setPerm("crm_products", "view", v)} />
<PermRow label="Add" value={cprod.add} onChange={(v) => setPerm("crm_products", "add", v)} />
<PermRow label="Edit / Delete" value={cprod.edit} onChange={(v) => setPerm("crm_products", "edit", v)} />
</PermSection>
{/* Manufacturing */}
<PermSection title="Manufacturing">
<PermRow label="View Inventory" value={mfg.view_inventory} onChange={(v) => setPerm("mfg", "view_inventory", v)} />
<PermRow label="Edit" value={mfg.edit} onChange={(v) => setPerm("mfg", "edit", v)} description="Change device status, delete, download NVS Binary" />
<PermRow label="Provision Device" value={mfg.provision} onChange={(v) => setPerm("mfg", "provision", v)} description="Flash devices via the Provisioning page" />
<SegmentedRow
label="Firmware"
value={segVal("mfg", "firmware_view", "firmware_edit")}
onChange={(val) => setSegmented("mfg", "firmware_view", "firmware_edit", val)}
/>
</PermSection>
</div>
{/* ── CRM Customers — full width ── */}
<section className="ui-section-card">
<h2 className="ui-section-card__title-row">
<span className="ui-section-card__title">CRM Customers</span>
</h2>
{/* Full Access row */}
<div
className="flex items-center justify-between gap-4 mt-3 mb-4 p-3 rounded-md"
style={{
border: `1px solid ${cc.full_access ? "var(--accent)" : "var(--border-secondary)"}`,
backgroundColor: cc.full_access ? "rgba(116,184,22,0.07)" : "transparent",
}}
>
<div>
<span className="text-sm font-semibold" style={{ color: cc.full_access ? "var(--accent)" : "var(--text-primary)" }}>
Full Access
</span>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Enables all CRM Customer permissions. When active, individual settings are locked.
</p>
</div>
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
type="button"
onClick={() => setPerm("crm_customers", "full_access", false)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={{ ...pillStyle("off", !cc.full_access), borderRight: "1px solid var(--border-primary)" }}
>
Disabled
</button>
<button
type="button"
onClick={() => setPerm("crm_customers", "full_access", true)}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
style={pillStyle("on", !!cc.full_access)}
>
Enabled
</button>
</div>
</div>
{/* Two-column grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
{/* Left column */}
<div>
<PermRow
label="Overview Tab"
value={cc.overview}
onChange={(v) => setPerm("crm_customers", "overview", v)}
disabled={ccLocked}
/>
<SegmentedRow
label="Orders"
value={segVal("crm_customers", "orders_view", "orders_edit")}
onChange={(val) => setSegmented("crm_customers", "orders_view", "orders_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Quotations"
value={segVal("crm_customers", "quotations_view", "quotations_edit")}
onChange={(val) => setSegmented("crm_customers", "quotations_view", "quotations_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Files & Media"
value={segVal("crm_customers", "files_view", "files_edit")}
onChange={(val) => setSegmented("crm_customers", "files_view", "files_edit", val)}
disabled={ccLocked}
/>
<SegmentedRow
label="Devices Tab"
value={segVal("crm_customers", "devices_view", "devices_edit")}
onChange={(val) => setSegmented("crm_customers", "devices_view", "devices_edit", val)}
disabled={ccLocked}
/>
</div>
{/* Right column */}
<div>
{/* Add + Delete on one row */}
<div
className="flex items-center justify-between gap-4 py-2.5"
style={{
borderBottom: "1px solid var(--border-secondary)",
opacity: ccLocked ? 0.4 : 1,
pointerEvents: ccLocked ? "none" : "auto",
}}
>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Customer</span>
<div className="flex items-center gap-10 flex-shrink-0">
{/* Add */}
<div className="flex items-center gap-1.5">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Add:</span>
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<button type="button" onClick={() => setPerm("crm_customers", "add", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.add), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
<button type="button" onClick={() => setPerm("crm_customers", "add", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.add)}>Enabled</button>
</div>
</div>
{/* Delete */}
<div className="flex items-center gap-1.5">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Delete:</span>
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<button type="button" onClick={() => setPerm("crm_customers", "delete", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.delete), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
<button type="button" onClick={() => setPerm("crm_customers", "delete", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.delete)}>Enabled</button>
</div>
</div>
</div>
</div>
<PermRow label="Comms: View" value={cc.comms_view} onChange={(v) => setPerm("crm_customers", "comms_view", v)} disabled={ccLocked} />
<PermRow label="Comms: Log Entry" value={cc.comms_log} onChange={(v) => setPerm("crm_customers", "comms_log", v)} disabled={ccLocked} />
<PermRow label="Comms: Edit Entries" value={cc.comms_edit} onChange={(v) => setPerm("crm_customers", "comms_edit", v)} disabled={ccLocked} />
<PermRow label="Comms: Compose & Send" value={cc.comms_compose} onChange={(v) => setPerm("crm_customers", "comms_compose", v)} disabled={ccLocked} />
</div>
</div>
</section>
{/* ── API Reference + MQTT ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<PermSection title="API Reference">
<PermRow label="Access API Reference Page" value={apir.access} onChange={(v) => setPerm("api_reference", "access", v)} />
</PermSection>
<PermSection title="MQTT">
<PermRow label="MQTT Access" value={mqtt.access} onChange={(v) => setPerm("mqtt", "access", v)} description="Dashboard, Commands, Logs, WebSocket" />
</PermSection>
</div>
</>
)}
{/* ── Submit ── */}
<div className="flex gap-3 pb-8">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
@@ -314,6 +612,7 @@ export default function StaffForm() {
Cancel Cancel
</button> </button>
</div> </div>
</form> </form>
</div> </div>
); );

View File

@@ -8,12 +8,7 @@ import NotesPanel from "../equipment/NotesPanel";
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<div> <div>
<dt <dt className="ui-field-label">{label}</dt>
className="text-xs font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{label}
</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}> <dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
{children || "-"} {children || "-"}
</dd> </dd>
@@ -255,16 +250,10 @@ export default function UserDetail() {
{/* Left column */} {/* Left column */}
<div className="space-y-6"> <div className="space-y-6">
{/* Account Info */} {/* Account Info */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Account Information</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Account Information
</h2>
<div style={{ display: "flex", gap: "1.5rem" }}> <div style={{ display: "flex", gap: "1.5rem" }}>
{/* Profile Photo */} {/* Profile Photo */}
<div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}> <div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
@@ -336,32 +325,20 @@ export default function UserDetail() {
</section> </section>
{/* Profile */} {/* Profile */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Profile</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Profile
</h2>
<dl className="grid grid-cols-1 gap-4"> <dl className="grid grid-cols-1 gap-4">
<Field label="Bio">{user.bio}</Field> <Field label="Bio">{user.bio}</Field>
</dl> </dl>
</section> </section>
{/* Timestamps */} {/* Timestamps */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Activity</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Activity
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Created Time">{user.created_time}</Field> <Field label="Created Time">{user.created_time}</Field>
<Field label="Created At">{user.createdAt}</Field> <Field label="Created At">{user.createdAt}</Field>
@@ -373,16 +350,10 @@ export default function UserDetail() {
{/* Right column */} {/* Right column */}
<div className="space-y-6"> <div className="space-y-6">
{/* Security */} {/* Security */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Security</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Security
</h2>
<dl className="grid grid-cols-2 gap-4"> <dl className="grid grid-cols-2 gap-4">
<Field label="Settings PIN">{user.settingsPIN ? "****" : "-"}</Field> <Field label="Settings PIN">{user.settingsPIN ? "****" : "-"}</Field>
<Field label="Quick Settings PIN">{user.quickSettingsPIN ? "****" : "-"}</Field> <Field label="Quick Settings PIN">{user.quickSettingsPIN ? "****" : "-"}</Field>
@@ -390,16 +361,10 @@ export default function UserDetail() {
</section> </section>
{/* Friends */} {/* Friends */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Friends</h2>
> </div>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Friends
</h2>
<dl className="grid grid-cols-2 gap-4"> <dl className="grid grid-cols-2 gap-4">
<Field label="Friends"> <Field label="Friends">
{user.friendsList?.length ?? 0} {user.friendsList?.length ?? 0}
@@ -411,17 +376,9 @@ export default function UserDetail() {
</section> </section>
{/* Assigned Devices */} {/* Assigned Devices */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Assigned Devices ({devices.length})</h2>
>
<div className="flex items-center justify-between mb-4">
<h2
className="text-lg font-semibold"
style={{ color: "var(--text-heading)" }}
>
Assigned Devices ({devices.length})
</h2>
{canEdit && ( {canEdit && (
<button <button
onClick={openAssignPanel} onClick={openAssignPanel}

View File

@@ -87,6 +87,11 @@ export default function UserForm() {
} }
const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
};
return ( return (
<div> <div>
@@ -129,16 +134,13 @@ export default function UserForm() {
{/* ===== Left Column ===== */} {/* ===== Left Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Account Info --- */} {/* --- Account Info --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Account Information</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Account Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Display Name * Display Name *
</label> </label>
<input <input
@@ -147,10 +149,11 @@ export default function UserForm() {
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Email * Email *
</label> </label>
<input <input
@@ -159,22 +162,24 @@ export default function UserForm() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Phone Number Phone Number
</label> </label>
<input <input
type="tel" type="text"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="e.g. +1234567890" placeholder="e.g. +1234567890"
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
UID UID
</label> </label>
<input <input
@@ -182,38 +187,49 @@ export default function UserForm() {
value={uid} value={uid}
onChange={(e) => setUid(e.target.value)} onChange={(e) => setUid(e.target.value)}
className={inputClass} className={inputClass}
style={isEdit ? { ...inputStyle, opacity: 0.5 } : inputStyle}
disabled={isEdit} disabled={isEdit}
style={isEdit ? { opacity: 0.5 } : undefined}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">Status</label>
Status <div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-input)" }}>
</label> {[
<select { value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d" },
value={status} { value: "blocked", label: "Blocked", activeColor: "#f34b4b", activeBg: "#3b1a1a" },
onChange={(e) => setStatus(e.target.value)} ].map((opt, idx) => {
className={inputClass} const isActive = status === opt.value;
> return (
<option value="">Select status</option> <button
<option value="active">Active</option> key={opt.value}
<option value="blocked">Blocked</option> type="button"
</select> onClick={() => setStatus(opt.value)}
style={{
flex: 1, padding: "8px 0", fontSize: 13, fontWeight: 600,
border: "none", cursor: "pointer",
backgroundColor: isActive ? opt.activeBg : "var(--bg-input)",
color: isActive ? opt.activeColor : "var(--text-secondary)",
borderRight: idx === 0 ? "1px solid var(--border-input)" : "none",
transition: "background-color 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
</div> </div>
</div> </div>
</section> </section>
{/* --- Profile --- */} {/* --- Profile --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Profile</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Profile
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Title Title
</label> </label>
<input <input
@@ -222,10 +238,11 @@ export default function UserForm() {
onChange={(e) => setUserTitle(e.target.value)} onChange={(e) => setUserTitle(e.target.value)}
placeholder="e.g. Church Administrator" placeholder="e.g. Church Administrator"
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Photo URL Photo URL
</label> </label>
<input <input
@@ -233,10 +250,11 @@ export default function UserForm() {
value={photoUrl} value={photoUrl}
onChange={(e) => setPhotoUrl(e.target.value)} onChange={(e) => setPhotoUrl(e.target.value)}
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Bio Bio
</label> </label>
<textarea <textarea
@@ -244,7 +262,7 @@ export default function UserForm() {
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
rows={3} rows={3}
className={inputClass} className={inputClass}
style={{ resize: "vertical" }} style={{ ...inputStyle, resize: "vertical" }}
/> />
</div> </div>
</div> </div>
@@ -254,36 +272,37 @@ export default function UserForm() {
{/* ===== Right Column ===== */} {/* ===== Right Column ===== */}
<div className="space-y-6"> <div className="space-y-6">
{/* --- Security --- */} {/* --- Security --- */}
<section <section className="ui-section-card">
className="rounded-lg border p-6" <div className="ui-section-card__title-row">
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} <h2 className="ui-section-card__header-title">Security</h2>
> </div>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Security
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Settings PIN Settings PIN
</label> </label>
<input <input
type="text" type="text"
inputMode="numeric"
value={settingsPIN} value={settingsPIN}
onChange={(e) => setSettingsPIN(e.target.value)} onChange={(e) => setSettingsPIN(e.target.value.replace(/\D/g, ""))}
placeholder="e.g. 1234" placeholder="e.g. 1234"
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}> <label className="ui-form-label">
Quick Settings PIN Quick Settings PIN
</label> </label>
<input <input
type="text" type="text"
inputMode="numeric"
value={quickSettingsPIN} value={quickSettingsPIN}
onChange={(e) => setQuickSettingsPIN(e.target.value)} onChange={(e) => setQuickSettingsPIN(e.target.value.replace(/\D/g, ""))}
placeholder="e.g. 0000" placeholder="e.g. 0000"
className={inputClass} className={inputClass}
style={inputStyle}
/> />
</div> </div>
</div> </div>

View File

@@ -33,6 +33,8 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
} }
# WebSocket support for MQTT live data # WebSocket support for MQTT live data