Compare commits
2 Commits
b280d62ee5
...
8c15c932b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c15c932b6 | |||
| c62188fda6 |
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ MQTT_BROKER_PORT=1883
|
||||
MQTT_ADMIN_USERNAME=admin
|
||||
MQTT_ADMIN_PASSWORD=your-mqtt-admin-password
|
||||
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)
|
||||
MQTT_SECRET=change-me-in-production
|
||||
|
||||
@@ -26,3 +28,10 @@ NGINX_PORT=80
|
||||
SQLITE_DB_PATH=./mqtt_data.db
|
||||
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
||||
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
@@ -33,3 +33,6 @@ Thumbs.db
|
||||
.MAIN-APP-REFERENCE/
|
||||
|
||||
.project-vesper-plan.md
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
@@ -1,5 +1,15 @@
|
||||
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
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -10,45 +10,141 @@ class Role(str, Enum):
|
||||
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
|
||||
add: 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):
|
||||
melodies: SectionPermissions = SectionPermissions()
|
||||
devices: SectionPermissions = SectionPermissions()
|
||||
app_users: SectionPermissions = SectionPermissions()
|
||||
equipment: SectionPermissions = SectionPermissions()
|
||||
manufacturing: SectionPermissions = SectionPermissions()
|
||||
mqtt: bool = False
|
||||
melodies: MelodiesPermissions = MelodiesPermissions()
|
||||
devices: DevicesPermissions = DevicesPermissions()
|
||||
app_users: AppUsersPermissions = AppUsersPermissions()
|
||||
issues_notes: IssuesNotesPermissions = IssuesNotesPermissions()
|
||||
mail: MailPermissions = MailPermissions()
|
||||
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]:
|
||||
if role in ("sysadmin", "admin"):
|
||||
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":
|
||||
return {
|
||||
"melodies": full,
|
||||
"devices": full,
|
||||
"app_users": full,
|
||||
"equipment": full,
|
||||
"manufacturing": view_only,
|
||||
"mqtt": True,
|
||||
"melodies": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True, "archetype_access": True, "settings_access": True, "compose_access": True},
|
||||
"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": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True},
|
||||
"issues_notes": {"view": True, "add": True, "delete": True, "edit": True},
|
||||
"mail": {"view": True, "compose": True, "reply": 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
|
||||
return {
|
||||
"melodies": view_only,
|
||||
"devices": view_only,
|
||||
"app_users": view_only,
|
||||
"equipment": view_only,
|
||||
"manufacturing": {"view": False, "add": False, "edit": False, "delete": False},
|
||||
"mqtt": 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, "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, "delete": False, "safe_edit": False, "full_edit": False},
|
||||
"issues_notes": {"view": True, "add": False, "delete": False, "edit": 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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
from typing import List, Dict, Any
|
||||
import json
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
||||
mqtt_admin_password: str = ""
|
||||
mqtt_secret: str = "change-me-in-production"
|
||||
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
||||
mqtt_client_id: str = "bellsystems-admin-panel"
|
||||
|
||||
# SQLite (MQTT data storage)
|
||||
sqlite_db_path: str = "./mqtt_data.db"
|
||||
@@ -37,6 +38,30 @@ class Settings(BaseSettings):
|
||||
backend_cors_origins: str = '["http://localhost:5173"]'
|
||||
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)
|
||||
deploy_secret: str = ""
|
||||
deploy_project_path: str = "/app"
|
||||
@@ -45,6 +70,14 @@ class Settings(BaseSettings):
|
||||
def cors_origins(self) -> List[str]:
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
0
backend/crm/__init__.py
Normal file
417
backend/crm/comms_router.py
Normal 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)
|
||||
71
backend/crm/customers_router.py
Normal 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
@@ -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
|
||||
|
||||
|
||||
|
||||
104
backend/crm/mail_accounts.py
Normal 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
|
||||
35
backend/crm/media_router.py
Normal 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
@@ -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
@@ -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}")
|
||||
305
backend/crm/nextcloud_router.py
Normal 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}
|
||||
57
backend/crm/orders_router.py
Normal 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)
|
||||
141
backend/crm/quotation_models.py
Normal 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
|
||||
101
backend/crm/quotations_router.py
Normal 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)
|
||||
494
backend/crm/quotations_service.py
Normal 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
@@ -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
@@ -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()
|
||||
@@ -7,6 +7,8 @@ from devices.models import (
|
||||
DeviceUsersResponse, DeviceUserInfo,
|
||||
)
|
||||
from devices import service
|
||||
from mqtt import database as mqtt_db
|
||||
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||
|
||||
@@ -67,3 +69,13 @@ async def delete_device(
|
||||
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
||||
):
|
||||
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])
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
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):
|
||||
id: str
|
||||
hw_type: str # "vs", "vp", "vx"
|
||||
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro"
|
||||
channel: str # "stable", "beta", "alpha", "testing"
|
||||
version: str # semver e.g. "1.4.2"
|
||||
version: str # semver e.g. "1.5"
|
||||
filename: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
update_type: UpdateType = UpdateType.mandatory
|
||||
min_fw_version: Optional[str] = None # minimum fw version required to install this
|
||||
uploaded_at: str
|
||||
notes: Optional[str] = None
|
||||
is_latest: bool = False
|
||||
@@ -20,12 +29,19 @@ class FirmwareListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class FirmwareLatestResponse(BaseModel):
|
||||
class FirmwareMetadataResponse(BaseModel):
|
||||
"""Returned by both /latest and /{version}/info endpoints."""
|
||||
hw_type: str
|
||||
channel: str
|
||||
version: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
update_type: UpdateType
|
||||
min_fw_version: Optional[str] = None
|
||||
download_url: str
|
||||
uploaded_at: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# Keep backwards-compatible alias
|
||||
FirmwareLatestResponse = FirmwareMetadataResponse
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
|
||||
from auth.models import TokenPayload
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/firmware", tags=["firmware"])
|
||||
@@ -15,6 +15,8 @@ async def upload_firmware(
|
||||
hw_type: str = Form(...),
|
||||
channel: str = Form(...),
|
||||
version: str = Form(...),
|
||||
update_type: UpdateType = Form(UpdateType.mandatory),
|
||||
min_fw_version: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
@@ -25,6 +27,8 @@ async def upload_firmware(
|
||||
channel=channel,
|
||||
version=version,
|
||||
file_bytes=file_bytes,
|
||||
update_type=update_type,
|
||||
min_fw_version=min_fw_version,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@@ -39,7 +43,7 @@ def list_firmware(
|
||||
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):
|
||||
"""Returns metadata for the latest firmware for a given hw_type + channel.
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
def download_firmware(hw_type: str, channel: str, version: str):
|
||||
"""Download the firmware binary. No auth required — devices call this directly."""
|
||||
|
||||
@@ -8,11 +8,11 @@ from fastapi import HTTPException
|
||||
from config import settings
|
||||
from shared.firebase import get_db
|
||||
from shared.exceptions import NotFoundError
|
||||
from firmware.models import FirmwareVersion, FirmwareLatestResponse
|
||||
from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
@@ -36,23 +36,43 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
|
||||
filename=data.get("filename", "firmware.bin"),
|
||||
size_bytes=data.get("size_bytes", 0),
|
||||
sha256=data.get("sha256", ""),
|
||||
update_type=data.get("update_type", UpdateType.mandatory),
|
||||
min_fw_version=data.get("min_fw_version"),
|
||||
uploaded_at=uploaded_str,
|
||||
notes=data.get("notes"),
|
||||
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(
|
||||
hw_type: str,
|
||||
channel: str,
|
||||
version: str,
|
||||
file_bytes: bytes,
|
||||
update_type: UpdateType = UpdateType.mandatory,
|
||||
min_fw_version: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> FirmwareVersion:
|
||||
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:
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -83,6 +103,8 @@ def upload_firmware(
|
||||
"filename": "firmware.bin",
|
||||
"size_bytes": len(file_bytes),
|
||||
"sha256": sha256,
|
||||
"update_type": update_type.value,
|
||||
"min_fw_version": min_fw_version,
|
||||
"uploaded_at": now,
|
||||
"notes": notes,
|
||||
"is_latest": True,
|
||||
@@ -108,7 +130,7 @@ def list_firmware(
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||
if channel not in VALID_CHANNELS:
|
||||
@@ -126,18 +148,29 @@ def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse:
|
||||
if not docs:
|
||||
raise NotFoundError("Firmware")
|
||||
|
||||
fw = _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,
|
||||
channel=fw.channel,
|
||||
version=fw.version,
|
||||
size_bytes=fw.size_bytes,
|
||||
sha256=fw.sha256,
|
||||
download_url=download_url,
|
||||
uploaded_at=fw.uploaded_at,
|
||||
notes=fw.notes,
|
||||
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
|
||||
|
||||
|
||||
def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetadataResponse:
|
||||
"""Fetch metadata for a specific version. Used by devices resolving upgrade chains."""
|
||||
if hw_type not in VALID_HW_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||
if channel not in VALID_CHANNELS:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||
|
||||
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:
|
||||
|
||||
@@ -17,6 +17,15 @@ from builder.router import router as builder_router
|
||||
from manufacturing.router import router as manufacturing_router
|
||||
from firmware.router import router as firmware_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 import database as mqtt_db
|
||||
from melodies import service as melody_service
|
||||
@@ -50,6 +59,30 @@ app.include_router(builder_router)
|
||||
app.include_router(manufacturing_router)
|
||||
app.include_router(firmware_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")
|
||||
@@ -59,12 +92,20 @@ async def startup():
|
||||
await melody_service.migrate_from_firestore()
|
||||
mqtt_manager.start(asyncio.get_event_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")
|
||||
async def shutdown():
|
||||
mqtt_manager.stop()
|
||||
await mqtt_db.close_db()
|
||||
await close_nextcloud_client()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -74,3 +115,4 @@ async def health_check():
|
||||
"firebase": firebase_initialized,
|
||||
"mqtt": mqtt_manager.connected,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@ from enum import Enum
|
||||
|
||||
|
||||
class BoardType(str, Enum):
|
||||
vs = "vs" # Vesper
|
||||
vp = "vp" # Vesper Plus
|
||||
vx = "vx" # Vesper Pro
|
||||
cb = "cb" # Chronos
|
||||
cp = "cp" # Chronos Pro
|
||||
am = "am" # Agnus Mini
|
||||
ab = "ab" # Agnus
|
||||
vesper = "vesper"
|
||||
vesper_plus = "vesper_plus"
|
||||
vesper_pro = "vesper_pro"
|
||||
chronos = "chronos"
|
||||
chronos_pro = "chronos_pro"
|
||||
agnus_mini = "agnus_mini"
|
||||
agnus = "agnus"
|
||||
|
||||
|
||||
BOARD_TYPE_LABELS = {
|
||||
"vs": "Vesper",
|
||||
"vp": "Vesper Plus",
|
||||
"vx": "Vesper Pro",
|
||||
"cb": "Chronos",
|
||||
"cp": "Chronos Pro",
|
||||
"am": "Agnus Mini",
|
||||
"ab": "Agnus",
|
||||
"vesper": "Vesper",
|
||||
"vesper_plus": "Vesper+",
|
||||
"vesper_pro": "Vesper Pro",
|
||||
"chronos": "Chronos",
|
||||
"chronos_pro": "Chronos Pro",
|
||||
"agnus_mini": "Agnus Mini",
|
||||
"agnus": "Agnus",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class MqttManager:
|
||||
|
||||
self._client = paho_mqtt.Client(
|
||||
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="bellsystems-admin-panel",
|
||||
client_id=settings.mqtt_client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
|
||||
@@ -64,6 +64,8 @@ class MqttManager:
|
||||
client.subscribe([
|
||||
("vesper/+/data", 1),
|
||||
("vesper/+/status/heartbeat", 1),
|
||||
("vesper/+/status/alerts", 1),
|
||||
("vesper/+/status/info", 0),
|
||||
("vesper/+/logs", 1),
|
||||
])
|
||||
else:
|
||||
|
||||
@@ -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_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:
|
||||
await _db.execute(stmt)
|
||||
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}")
|
||||
|
||||
|
||||
@@ -252,3 +407,37 @@ async def purge_loop():
|
||||
await purge_old_data()
|
||||
except Exception as 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]
|
||||
|
||||
@@ -18,6 +18,10 @@ async def handle_message(serial: str, topic_type: str, payload: dict):
|
||||
try:
|
||||
if topic_type == "status/heartbeat":
|
||||
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":
|
||||
await _handle_log(serial, payload)
|
||||
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):
|
||||
# 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", {})
|
||||
await db.insert_heartbeat(
|
||||
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):
|
||||
status = payload.get("status", "")
|
||||
|
||||
|
||||
@@ -84,3 +84,15 @@ class CommandSendResponse(BaseModel):
|
||||
success: bool
|
||||
command_id: int
|
||||
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]
|
||||
|
||||
@@ -10,3 +10,6 @@ python-multipart==0.0.20
|
||||
bcrypt==4.0.1
|
||||
aiosqlite==0.20.0
|
||||
resend==2.10.0
|
||||
httpx>=0.27.0
|
||||
weasyprint>=62.0
|
||||
jinja2>=3.1.0
|
||||
|
After Width: | Height: | Size: 21 KiB |
BIN
backend/templates/linktree.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
backend/templates/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
708
backend/templates/quotation.html
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
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'
|
||||
|
||||
Returns raw bytes ready to flash at 0x9000.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>BellSystems Admin</title>
|
||||
</head>
|
||||
|
||||
BIN
frontend/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -30,6 +30,13 @@ import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
|
||||
import ProvisioningWizard from "./manufacturing/ProvisioningWizard";
|
||||
import FirmwareManager from "./firmware/FirmwareManager";
|
||||
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 }) {
|
||||
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="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 */}
|
||||
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
||||
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||
|
||||
25
frontend/src/assets/comms/call.svg
Normal 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 |
8
frontend/src/assets/comms/email.svg
Normal 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 |
4
frontend/src/assets/comms/inbound.svg
Normal 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 |
17
frontend/src/assets/comms/inperson.svg
Normal 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 |
8
frontend/src/assets/comms/internal.svg
Normal 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 |
2
frontend/src/assets/comms/mail.svg
Normal 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 |
2
frontend/src/assets/comms/note.svg
Normal 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 |
4
frontend/src/assets/comms/outbound.svg
Normal 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 |
2
frontend/src/assets/comms/sms.svg
Normal 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 |
12
frontend/src/assets/comms/whatsapp.svg
Normal 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 |
@@ -71,6 +71,25 @@ export function AuthProvider({ children }) {
|
||||
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) => {
|
||||
if (!user) return false;
|
||||
// sysadmin and admin have full access
|
||||
@@ -79,13 +98,22 @@ export function AuthProvider({ children }) {
|
||||
const perms = user.permissions;
|
||||
if (!perms) return false;
|
||||
|
||||
// MQTT is a global flag
|
||||
if (section === "mqtt") {
|
||||
return !!perms.mqtt;
|
||||
// crm_orders is derived from crm_customers
|
||||
if (section === "crm_orders") {
|
||||
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];
|
||||
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];
|
||||
};
|
||||
|
||||
|
||||
141
frontend/src/crm/components/CommIcons.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
928
frontend/src/crm/components/ComposeEmailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
669
frontend/src/crm/components/MailViewModal.jsx
Normal 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, "<")}</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'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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2883
frontend/src/crm/customers/CustomerDetail.jsx
Normal file
579
frontend/src/crm/customers/CustomerForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/crm/customers/CustomerList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/crm/customers/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CustomerList } from "./CustomerList";
|
||||
export { default as CustomerForm } from "./CustomerForm";
|
||||
export { default as CustomerDetail } from "./CustomerDetail";
|
||||
466
frontend/src/crm/inbox/CommsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
frontend/src/crm/inbox/InboxPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
838
frontend/src/crm/mail/MailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
frontend/src/crm/orders/OrderDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
662
frontend/src/crm/orders/OrderForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/crm/orders/OrderList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/crm/orders/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as OrderList } from "./OrderList";
|
||||
export { default as OrderForm } from "./OrderForm";
|
||||
export { default as OrderDetail } from "./OrderDetail";
|
||||
635
frontend/src/crm/products/ProductForm.jsx
Normal 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="Min–Max 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 (min–max 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/crm/products/ProductList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
frontend/src/crm/products/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ProductList } from "./ProductList";
|
||||
export { default as ProductForm } from "./ProductForm";
|
||||
1070
frontend/src/crm/quotations/QuotationForm.jsx
Normal file
438
frontend/src/crm/quotations/QuotationList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
frontend/src/crm/quotations/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as QuotationForm } from "./QuotationForm";
|
||||
export { default as QuotationList } from "./QuotationList";
|
||||
1490
frontend/src/developer/ApiReferencePage.jsx
Normal file
@@ -1599,6 +1599,7 @@ export default function DeviceDetail() {
|
||||
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
|
||||
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
|
||||
const lastStrikeRequestAtRef = useRef(0);
|
||||
const [hwProduct, setHwProduct] = useState(null);
|
||||
|
||||
// --- Section edit modal open/close state ---
|
||||
const [editingLocation, setEditingLocation] = useState(false);
|
||||
@@ -1641,6 +1642,25 @@ export default function DeviceDetail() {
|
||||
setDeviceUsers([]);
|
||||
}).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) => {
|
||||
const issues = (data.notes || []).filter(
|
||||
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
|
||||
@@ -1809,9 +1829,8 @@ export default function DeviceDetail() {
|
||||
: null;
|
||||
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
|
||||
|
||||
const hwImageMap = { VesperPlus: "/devices/VesperPlus.png" };
|
||||
const hwVariant = "VesperPlus";
|
||||
const hwImage = hwImageMap[hwVariant] || hwImageMap.VesperPlus;
|
||||
const hwVariant = hwProduct?.name || "VesperPlus";
|
||||
const hwImage = hwProduct?.photo_url ? `/api${hwProduct.photo_url}` : "/devices/VesperPlus.png";
|
||||
|
||||
const locationCard = (
|
||||
<section className="device-section-card">
|
||||
|
||||
@@ -250,16 +250,13 @@ export default function DeviceForm() {
|
||||
{/* ===== Left Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Basic Info --- */}
|
||||
<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)" }}>
|
||||
Basic Information
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Basic Information</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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 *
|
||||
</label>
|
||||
<input
|
||||
@@ -271,7 +268,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -283,7 +280,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Location Coordinates
|
||||
</label>
|
||||
<input
|
||||
@@ -295,7 +292,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Device Photo URL
|
||||
</label>
|
||||
<input
|
||||
@@ -320,13 +317,10 @@ export default function DeviceForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Device Attributes --- */}
|
||||
<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)" }}>
|
||||
Device Attributes
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Device Attributes</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-wrap gap-4 md:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
@@ -385,7 +379,7 @@ export default function DeviceForm() {
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Total Bells
|
||||
</label>
|
||||
<input
|
||||
@@ -397,7 +391,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Device Locale
|
||||
</label>
|
||||
<select
|
||||
@@ -413,7 +407,7 @@ export default function DeviceForm() {
|
||||
</select>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -425,7 +419,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -437,7 +431,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Serial Log Level
|
||||
</label>
|
||||
<input
|
||||
@@ -449,7 +443,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
SD Log Level
|
||||
</label>
|
||||
<input
|
||||
@@ -464,16 +458,13 @@ export default function DeviceForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Network Settings --- */}
|
||||
<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)" }}>
|
||||
Network Settings
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Network Settings</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Hostname
|
||||
</label>
|
||||
<input
|
||||
@@ -496,7 +487,7 @@ export default function DeviceForm() {
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
WebSocket URL
|
||||
</label>
|
||||
<input
|
||||
@@ -507,7 +498,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Church Assistant URL
|
||||
</label>
|
||||
<input
|
||||
@@ -524,16 +515,13 @@ export default function DeviceForm() {
|
||||
{/* ===== Right Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Subscription --- */}
|
||||
<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)" }}>
|
||||
Subscription
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Subscription</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Tier
|
||||
</label>
|
||||
<select
|
||||
@@ -549,7 +537,7 @@ export default function DeviceForm() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
@@ -560,7 +548,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Duration (months)
|
||||
</label>
|
||||
<input
|
||||
@@ -572,7 +560,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Max Users
|
||||
</label>
|
||||
<input
|
||||
@@ -584,7 +572,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Max Outputs
|
||||
</label>
|
||||
<input
|
||||
@@ -599,16 +587,13 @@ export default function DeviceForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Clock Settings --- */}
|
||||
<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)" }}>
|
||||
Clock Settings
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Clock Settings</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Ring Alerts
|
||||
</label>
|
||||
<select
|
||||
@@ -624,7 +609,7 @@ export default function DeviceForm() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Ring Intervals
|
||||
</label>
|
||||
<input
|
||||
@@ -645,7 +630,7 @@ export default function DeviceForm() {
|
||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -656,7 +641,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -667,7 +652,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Hour Alerts Bell
|
||||
</label>
|
||||
<input
|
||||
@@ -679,7 +664,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@@ -691,7 +676,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Quarter Alerts Bell
|
||||
</label>
|
||||
<input
|
||||
@@ -703,7 +688,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Backlight Output
|
||||
</label>
|
||||
<input
|
||||
@@ -788,16 +773,13 @@ export default function DeviceForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Statistics --- */}
|
||||
<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)" }}>
|
||||
Statistics & Warranty
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Statistics & Warranty</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Total Playbacks
|
||||
</label>
|
||||
<input
|
||||
@@ -809,7 +791,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Total Hammer Strikes
|
||||
</label>
|
||||
<input
|
||||
@@ -821,7 +803,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Total Warnings Given
|
||||
</label>
|
||||
<input
|
||||
@@ -833,7 +815,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -853,7 +835,7 @@ export default function DeviceForm() {
|
||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Warranty Start
|
||||
</label>
|
||||
<input
|
||||
@@ -864,7 +846,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Warranty Period (months)
|
||||
</label>
|
||||
<input
|
||||
@@ -876,7 +858,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Last Maintained On
|
||||
</label>
|
||||
<input
|
||||
@@ -887,7 +869,7 @@ export default function DeviceForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Maintenance Period (months)
|
||||
</label>
|
||||
<input
|
||||
|
||||
@@ -20,12 +20,7 @@ const categoryStyle = (cat) => {
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<dt
|
||||
className="text-xs font-medium uppercase tracking-wide"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</dt>
|
||||
<dt className="ui-field-label">{label}</dt>
|
||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
|
||||
{children || "-"}
|
||||
</dd>
|
||||
@@ -158,16 +153,10 @@ export default function NoteDetail() {
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6">
|
||||
{/* Note Content */}
|
||||
<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)" }}
|
||||
>
|
||||
Content
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Content</h2>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm whitespace-pre-wrap"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
@@ -177,16 +166,10 @@ export default function NoteDetail() {
|
||||
</section>
|
||||
|
||||
{/* Timestamps */}
|
||||
<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)" }}
|
||||
>
|
||||
Details
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Details</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Category">
|
||||
<span
|
||||
@@ -211,16 +194,10 @@ export default function NoteDetail() {
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6">
|
||||
{/* Linked Device */}
|
||||
<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)" }}
|
||||
>
|
||||
Linked Device
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Linked Device</h2>
|
||||
</div>
|
||||
{note.device_id ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -247,16 +224,10 @@ export default function NoteDetail() {
|
||||
</section>
|
||||
|
||||
{/* Linked 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)" }}
|
||||
>
|
||||
Linked User
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Linked User</h2>
|
||||
</div>
|
||||
{note.user_id ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -132,16 +132,13 @@ export default function NoteForm() {
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Left Column — Note Content */}
|
||||
<div className="space-y-6">
|
||||
<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)" }}>
|
||||
Note Details
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Note Details</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
@@ -159,7 +156,7 @@ export default function NoteForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Content *
|
||||
</label>
|
||||
<textarea
|
||||
@@ -178,7 +175,7 @@ export default function NoteForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
@@ -204,16 +201,13 @@ export default function NoteForm() {
|
||||
|
||||
{/* Right Column — Associations */}
|
||||
<div className="space-y-6">
|
||||
<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)" }}>
|
||||
Link To
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Link To</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Device (optional)
|
||||
</label>
|
||||
<select
|
||||
@@ -235,7 +229,7 @@ export default function NoteForm() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
User (optional)
|
||||
</label>
|
||||
<select
|
||||
|
||||
@@ -3,9 +3,13 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import api from "../api/client";
|
||||
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vs", label: "Vesper (VS)" },
|
||||
{ value: "vp", label: "Vesper+ (VP)" },
|
||||
{ value: "vx", label: "VesperPro (VX)" },
|
||||
{ value: "vesper", label: "Vesper" },
|
||||
{ value: "vesper_plus", label: "Vesper+" },
|
||||
{ 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"];
|
||||
@@ -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) {
|
||||
if (!bytes) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -61,9 +83,11 @@ export default function FirmwareManager() {
|
||||
const [channelFilter, setChannelFilter] = useState("");
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadHwType, setUploadHwType] = useState("vs");
|
||||
const [uploadHwType, setUploadHwType] = useState("vesper");
|
||||
const [uploadChannel, setUploadChannel] = useState("stable");
|
||||
const [uploadVersion, setUploadVersion] = useState("");
|
||||
const [uploadUpdateType, setUploadUpdateType] = useState("mandatory");
|
||||
const [uploadMinFw, setUploadMinFw] = useState("");
|
||||
const [uploadNotes, setUploadNotes] = useState("");
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -104,6 +128,8 @@ export default function FirmwareManager() {
|
||||
formData.append("hw_type", uploadHwType);
|
||||
formData.append("channel", uploadChannel);
|
||||
formData.append("version", uploadVersion);
|
||||
formData.append("update_type", uploadUpdateType);
|
||||
if (uploadMinFw) formData.append("min_fw_version", uploadMinFw);
|
||||
if (uploadNotes) formData.append("notes", uploadNotes);
|
||||
formData.append("file", uploadFile);
|
||||
|
||||
@@ -120,6 +146,8 @@ export default function FirmwareManager() {
|
||||
|
||||
setShowUpload(false);
|
||||
setUploadVersion("");
|
||||
setUploadUpdateType("mandatory");
|
||||
setUploadMinFw("");
|
||||
setUploadNotes("");
|
||||
setUploadFile(null);
|
||||
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 (
|
||||
<div>
|
||||
@@ -173,58 +201,52 @@ export default function FirmwareManager() {
|
||||
{/* Upload form */}
|
||||
{showUpload && (
|
||||
<div
|
||||
className="rounded-lg border p-5 mb-5"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card mb-5"
|
||||
>
|
||||
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||
Upload New Firmware
|
||||
</h2>
|
||||
{/* Section title row */}
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Upload New Firmware</h2>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div
|
||||
className="text-sm rounded-md p-3 mb-3 border"
|
||||
style={{
|
||||
backgroundColor: "var(--danger-bg)",
|
||||
borderColor: "var(--danger)",
|
||||
color: "var(--danger-text)",
|
||||
}}
|
||||
className="text-sm rounded-md p-3 mb-4 border"
|
||||
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||
>
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleUpload} className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
<form onSubmit={handleUpload}>
|
||||
{/* 3-column panel layout — height driven by left panel */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1.5rem", alignItems: "stretch" }}>
|
||||
|
||||
{/* ── LEFT: Config ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* Board Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Board Type
|
||||
</label>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
|
||||
<select
|
||||
value={uploadHwType}
|
||||
onChange={(e) => setUploadHwType(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)",
|
||||
}}
|
||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{BOARD_TYPES.map((bt) => (
|
||||
<option key={bt.value} value={bt.value}>{bt.label}</option>
|
||||
))}
|
||||
</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>
|
||||
<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)",
|
||||
}}
|
||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{CHANNELS.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
@@ -232,56 +254,66 @@ export default function FirmwareManager() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Version
|
||||
</label>
|
||||
<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"
|
||||
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)",
|
||||
}}
|
||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
firmware.bin
|
||||
</label>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Min FW</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".bin"
|
||||
required
|
||||
onChange={(e) => setUploadFile(e.target.files[0] || null)}
|
||||
className="w-full text-sm"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
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" style={{ color: "var(--text-muted)" }}>
|
||||
Release Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={uploadNotes}
|
||||
onChange={(e) => setUploadNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="What changed in this version?"
|
||||
className="w-full px-3 py-2 rounded-md text-sm border resize-none"
|
||||
<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={{
|
||||
backgroundColor: "var(--bg-input)",
|
||||
borderColor: "var(--border-input)",
|
||||
color: "var(--text-primary)",
|
||||
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 className="flex gap-3 pt-1">
|
||||
</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}
|
||||
@@ -299,6 +331,118 @@ export default function FirmwareManager() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── MIDDLE: Release Notes ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>Release Notes</label>
|
||||
<textarea
|
||||
value={uploadNotes}
|
||||
onChange={(e) => setUploadNotes(e.target.value)}
|
||||
placeholder="What changed in this version?"
|
||||
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)",
|
||||
flex: 1,
|
||||
resize: "none",
|
||||
minHeight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: File drop + info ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)", marginBottom: "0.25rem" }}>File Upload</label>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f && f.name.endsWith(".bin")) setUploadFile(f);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
border: `2px dashed ${uploadFile ? "var(--btn-primary)" : "var(--border-input)"}`,
|
||||
borderRadius: "0.625rem",
|
||||
backgroundColor: uploadFile ? "var(--badge-blue-bg)" : "var(--bg-input)",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
padding: "1rem",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".bin"
|
||||
required
|
||||
onChange={(e) => setUploadFile(e.target.files[0] || null)}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
{uploadFile ? (
|
||||
<>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
|
||||
{uploadFile.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</form>
|
||||
</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)" }}>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)" }}>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)" }}>SHA-256</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>
|
||||
{loading ? (
|
||||
<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…
|
||||
</td>
|
||||
</tr>
|
||||
) : firmware.length === 0 ? (
|
||||
<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.{" "}
|
||||
{canAdd && (
|
||||
<button
|
||||
@@ -408,6 +554,12 @@ export default function FirmwareManager() {
|
||||
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
||||
{fw.version}
|
||||
</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)" }}>
|
||||
{formatBytes(fw.size_bytes)}
|
||||
</td>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
--text-primary: #e3e5ea;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #9ca3af;
|
||||
--text-more-muted: #818da1bb;
|
||||
--text-heading: #e3e5ea;
|
||||
--text-white: #ffffff;
|
||||
--text-link: #589cfa;
|
||||
@@ -28,6 +29,11 @@
|
||||
--btn-primary-hover: #82c91e;
|
||||
--btn-neutral: #778ca8;
|
||||
--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-hover: #e53e3e;
|
||||
@@ -42,6 +48,48 @@
|
||||
|
||||
--badge-blue-bg: #1e3a5f;
|
||||
--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 */
|
||||
@@ -624,12 +672,77 @@ input[type="range"]::-moz-range-thumb {
|
||||
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 {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.75rem;
|
||||
border-radius: var(--section-radius);
|
||||
background-color: var(--bg-card);
|
||||
padding: 2.25rem 2.5rem 2.5rem;
|
||||
padding: var(--section-padding);
|
||||
}
|
||||
|
||||
.device-section-card__title-row {
|
||||
@@ -642,9 +755,9 @@ input[type="range"]::-moz-range-thumb {
|
||||
}
|
||||
|
||||
.device-section-card__title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
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-bottom: 0;
|
||||
@@ -729,7 +842,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 1rem 2.5rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
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 */
|
||||
input[type="file"]::file-selector-button {
|
||||
background-color: var(--bg-card) !important;
|
||||
|
||||
@@ -1,4 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
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() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -11,9 +209,7 @@ export default function Header() {
|
||||
borderColor: "var(--border-primary)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||
BellCloud™ - Console
|
||||
</h2>
|
||||
<Breadcrumb />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
@@ -41,5 +237,3 @@ export default function Header() {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* my test string */
|
||||
@@ -8,7 +8,7 @@ const navItems = [
|
||||
label: "Melodies",
|
||||
permission: "melodies",
|
||||
children: [
|
||||
{ to: "/melodies", label: "Main Editor" },
|
||||
{ to: "/melodies", label: "Main Editor", exact: true },
|
||||
{ to: "/melodies/archetypes", label: "Archetypes" },
|
||||
{ to: "/melodies/settings", label: "Settings" },
|
||||
{ to: "/melodies/composer", label: "Composer" },
|
||||
@@ -20,17 +20,28 @@ const navItems = [
|
||||
label: "MQTT",
|
||||
permission: "mqtt",
|
||||
children: [
|
||||
{ to: "/mqtt", label: "Dashboard" },
|
||||
{ to: "/mqtt", label: "Dashboard", exact: true },
|
||||
{ to: "/mqtt/commands", label: "Commands" },
|
||||
{ to: "/mqtt/logs", label: "Logs" },
|
||||
],
|
||||
},
|
||||
{ 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",
|
||||
permission: "manufacturing",
|
||||
children: [
|
||||
{ to: "/manufacturing", label: "Device Inventory" },
|
||||
{ to: "/manufacturing", label: "Device Inventory", exact: true },
|
||||
{ to: "/manufacturing/batch/new", label: "New Batch" },
|
||||
{ to: "/manufacturing/provision", label: "Provision Device" },
|
||||
{ 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)]"
|
||||
}`;
|
||||
|
||||
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() {
|
||||
const { hasPermission, hasRole } = useAuth();
|
||||
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) => {
|
||||
if (!permission) return true;
|
||||
return hasPermission(permission, "view");
|
||||
};
|
||||
|
||||
// Settings visible only to sysadmin and 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 (
|
||||
<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}
|
||||
currentPath={location.pathname}
|
||||
locked={!hasAccess}
|
||||
open={openGroup === item.label}
|
||||
onToggle={() => handleGroupToggle(item.label)}
|
||||
/>
|
||||
) : (
|
||||
<NavLink
|
||||
@@ -85,7 +120,10 @@ export default function Sidebar() {
|
||||
to={hasAccess ? item.to : "#"}
|
||||
end={item.to === "/"}
|
||||
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">
|
||||
{item.label}
|
||||
@@ -100,16 +138,31 @@ export default function Sidebar() {
|
||||
})}
|
||||
</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 */}
|
||||
{canManageStaff && (
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<nav className="space-y-1">
|
||||
<CollapsibleGroup
|
||||
label="Settings"
|
||||
children={[
|
||||
{ to: "/settings/staff", label: "Staff" },
|
||||
]}
|
||||
children={settingsChildren}
|
||||
currentPath={location.pathname}
|
||||
open={openGroup === "Settings"}
|
||||
onToggle={() => handleGroupToggle("Settings")}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -118,25 +171,19 @@ export default function Sidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
||||
const isChildActive = children.some(
|
||||
(child) =>
|
||||
currentPath === child.to ||
|
||||
(child.to !== "/" && currentPath.startsWith(child.to + "/"))
|
||||
);
|
||||
const [open, setOpen] = useState(isChildActive);
|
||||
|
||||
const shouldBeOpen = open || isChildActive;
|
||||
function CollapsibleGroup({ label, children, currentPath, locked = false, open, onToggle }) {
|
||||
const childActive = isGroupActive(children, currentPath);
|
||||
const shouldBeOpen = open || childActive;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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 ${
|
||||
locked
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: isChildActive
|
||||
: childActive
|
||||
? "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}
|
||||
{locked && (
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
@@ -166,7 +213,7 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
||||
<NavLink
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
end
|
||||
end={child.exact === true}
|
||||
className={({ isActive }) =>
|
||||
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
@@ -183,5 +230,3 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vx", 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: "vs", 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: "am", 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: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
||||
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||
];
|
||||
|
||||
const BOARD_FAMILY_COLORS = {
|
||||
@@ -99,12 +99,11 @@ export default function BatchCreator() {
|
||||
|
||||
{!result ? (
|
||||
<div
|
||||
className="rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}>
|
||||
Batch Parameters
|
||||
</h2>
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Batch Parameters</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Board Type
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -147,7 +146,7 @@ export default function BatchCreator() {
|
||||
|
||||
{/* Board Revision */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Board Revision
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -169,7 +168,7 @@ export default function BatchCreator() {
|
||||
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
@@ -206,8 +205,7 @@ export default function BatchCreator() {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
|
||||
@@ -7,13 +7,13 @@ import api from "../api/client";
|
||||
|
||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
||||
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
||||
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
||||
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
||||
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
||||
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
||||
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
||||
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
||||
];
|
||||
|
||||
const BOARD_FAMILY_COLORS = {
|
||||
@@ -589,7 +589,16 @@ export default function DeviceInventory() {
|
||||
|
||||
const renderCell = (col, device) => {
|
||||
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 "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
|
||||
case "status": return <StatusBadge status={device.mfg_status} />;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import api from "../api/client";
|
||||
|
||||
const BOARD_TYPE_LABELS = {
|
||||
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro",
|
||||
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus",
|
||||
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
|
||||
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
|
||||
};
|
||||
|
||||
const STATUS_STYLES = {
|
||||
@@ -47,9 +47,7 @@ function StatusBadge({ status }) {
|
||||
function Field({ label, value, mono = false }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="ui-field-label mb-0.5">{label}</p>
|
||||
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
||||
{value || "—"}
|
||||
</p>
|
||||
@@ -320,11 +318,10 @@ export default function DeviceInventoryDetail() {
|
||||
)}
|
||||
|
||||
{/* Identity card */}
|
||||
<div className="rounded-lg border p-5 mb-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
||||
Device Identity
|
||||
</h2>
|
||||
<div className="ui-section-card mb-4">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Device Identity</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Serial Number" value={device?.serial_number} mono />
|
||||
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
||||
@@ -339,12 +336,9 @@ export default function DeviceInventoryDetail() {
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="rounded-lg border p-5 mb-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
Status
|
||||
</h2>
|
||||
<div className="ui-section-card mb-4">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Status</h2>
|
||||
{canEdit && !editingStatus && (
|
||||
<button
|
||||
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
|
||||
@@ -404,11 +398,10 @@ export default function DeviceInventoryDetail() {
|
||||
</div>
|
||||
|
||||
{/* Actions card */}
|
||||
<div className="rounded-lg border p-5 mb-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
||||
Actions
|
||||
</h2>
|
||||
<div className="ui-section-card mb-4">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Actions</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={downloadNvs}
|
||||
@@ -429,11 +422,10 @@ export default function DeviceInventoryDetail() {
|
||||
|
||||
{/* Assign to Customer card */}
|
||||
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
|
||||
<div className="rounded-lg border p-5"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
||||
Assign to Customer
|
||||
</h2>
|
||||
<div className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Assign to Customer</h2>
|
||||
</div>
|
||||
{assignSuccess ? (
|
||||
<div className="text-sm rounded-md p-3 border"
|
||||
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
|
||||
|
||||
@@ -7,13 +7,13 @@ import api from "../api/client";
|
||||
|
||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vx", 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: "vs", 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: "am", 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: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
|
||||
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||
{ value: "vesper", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro 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)
|
||||
|
||||
@@ -400,10 +400,7 @@ export default function MelodyComposer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<section className="ui-section-card">
|
||||
<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={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>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-lg border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<section className="ui-section-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-max border-separate border-spacing-0">
|
||||
<thead>
|
||||
@@ -633,10 +627,7 @@ export default function MelodyComposer() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<section className="ui-section-card">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>
|
||||
|
||||
@@ -58,9 +58,7 @@ function normalizeFileUrl(url) {
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dt className="ui-field-label">{label}</dt>
|
||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -70,9 +68,7 @@ function UrlField({ label, value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dt className="ui-field-label mb-1">{label}</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-sm font-mono flex-1 min-w-0"
|
||||
@@ -354,12 +350,11 @@ export default function MelodyDetail() {
|
||||
<div className="space-y-6">
|
||||
{/* Melody Information */}
|
||||
<section
|
||||
className="rounded-lg p-6 border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||
Melody Information
|
||||
</h2>
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Melody Information</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Color">
|
||||
{info.color ? (
|
||||
@@ -422,12 +417,11 @@ export default function MelodyDetail() {
|
||||
|
||||
{/* Identifiers */}
|
||||
<section
|
||||
className="rounded-lg p-6 border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||
Identifiers
|
||||
</h2>
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Identifiers</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Document ID">{melody.id}</Field>
|
||||
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
||||
@@ -444,12 +438,11 @@ export default function MelodyDetail() {
|
||||
<div className="space-y-6">
|
||||
{/* Default Settings */}
|
||||
<section
|
||||
className="rounded-lg p-6 border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||
Default Settings
|
||||
</h2>
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Default Settings</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Speed">
|
||||
{settings.speed != null ? (
|
||||
@@ -474,7 +467,7 @@ export default function MelodyDetail() {
|
||||
</Field>
|
||||
</div>
|
||||
<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>
|
||||
{settings.noteAssignments?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -512,10 +505,11 @@ export default function MelodyDetail() {
|
||||
|
||||
{/* Files */}
|
||||
<section
|
||||
className="rounded-lg p-6 border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card"
|
||||
>
|
||||
<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">
|
||||
<Field label="Available as Built-In">
|
||||
<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 */}
|
||||
{builtMelody?.progmem_code && (
|
||||
<section
|
||||
className="rounded-lg p-6 border mt-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card mt-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="ui-section-card__title-row">
|
||||
<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)" }}>
|
||||
PROGMEM code for built-in firmware playback · PID: <span className="font-mono">{builtMelody.pid}</span>
|
||||
</p>
|
||||
@@ -723,10 +716,11 @@ export default function MelodyDetail() {
|
||||
{/* Metadata section */}
|
||||
{melody.metadata && (
|
||||
<section
|
||||
className="rounded-lg p-6 border mt-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card mt-6"
|
||||
>
|
||||
<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">
|
||||
{melody.metadata.dateCreated && (
|
||||
<Field label="Date Created">
|
||||
@@ -750,10 +744,11 @@ export default function MelodyDetail() {
|
||||
|
||||
{/* Admin Notes section */}
|
||||
<section
|
||||
className="rounded-lg p-6 border mt-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
className="ui-section-card mt-6"
|
||||
>
|
||||
<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 ? (
|
||||
<div className="space-y-2">
|
||||
{melody.metadata.adminNotes.map((note, i) => (
|
||||
|
||||
@@ -46,12 +46,6 @@ const defaultSettings = {
|
||||
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 mutedStyle = { color: "var(--text-muted)" };
|
||||
|
||||
@@ -408,7 +402,7 @@ export default function MelodyForm() {
|
||||
return (
|
||||
<div>
|
||||
<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"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -533,13 +527,15 @@ export default function MelodyForm() {
|
||||
{/* ===== Left Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Melody Info Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2>
|
||||
<section className="ui-section-card">
|
||||
<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">
|
||||
{/* Name (localized) */}
|
||||
<div className="md:col-span-2">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
|
||||
@@ -561,7 +557,7 @@ export default function MelodyForm() {
|
||||
{/* Description (localized) */}
|
||||
<div className="md:col-span-2">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
|
||||
@@ -576,31 +572,31 @@ export default function MelodyForm() {
|
||||
</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}>
|
||||
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
||||
</select>
|
||||
</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}>
|
||||
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
||||
</select>
|
||||
</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} />
|
||||
</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} />
|
||||
</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} />
|
||||
{information.minSpeed > 0 && (
|
||||
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
|
||||
@@ -608,7 +604,7 @@ export default function MelodyForm() {
|
||||
</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} />
|
||||
{information.maxSpeed > 0 && (
|
||||
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
|
||||
@@ -617,7 +613,7 @@ export default function MelodyForm() {
|
||||
|
||||
{/* Color */}
|
||||
<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">
|
||||
<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" />
|
||||
@@ -645,7 +641,7 @@ export default function MelodyForm() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -665,16 +661,18 @@ export default function MelodyForm() {
|
||||
</section>
|
||||
|
||||
{/* --- Identifiers Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2>
|
||||
<section className="ui-section-card">
|
||||
<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>
|
||||
<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} />
|
||||
</div>
|
||||
{url && (
|
||||
<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 }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -685,11 +683,13 @@ export default function MelodyForm() {
|
||||
{/* ===== Right Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Default Settings Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2>
|
||||
<section className="ui-section-card">
|
||||
<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="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">
|
||||
<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>
|
||||
@@ -700,7 +700,7 @@ export default function MelodyForm() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -709,12 +709,12 @@ export default function MelodyForm() {
|
||||
</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} />
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
@@ -724,13 +724,13 @@ export default function MelodyForm() {
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-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)" }}>
|
||||
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
@@ -752,8 +752,10 @@ export default function MelodyForm() {
|
||||
</section>
|
||||
|
||||
{/* --- File Upload Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Files</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -768,7 +770,7 @@ export default function MelodyForm() {
|
||||
</label>
|
||||
</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 missingArchetype = Boolean(pid) && !builtMelody?.id;
|
||||
@@ -867,7 +869,7 @@ export default function MelodyForm() {
|
||||
</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) ? (
|
||||
<div className="mb-2 space-y-1">
|
||||
{(() => {
|
||||
@@ -916,8 +918,10 @@ export default function MelodyForm() {
|
||||
</div>
|
||||
|
||||
{/* --- Admin Notes Section --- */}
|
||||
<section className="rounded-lg p-6 border mt-6" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Admin Notes</h2>
|
||||
<section className="ui-section-card mt-6">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Admin Notes</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{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)" }}>
|
||||
|
||||
@@ -14,12 +14,7 @@ const DEFAULT_NOTE_ASSIGNMENT_COLORS = [
|
||||
"#F87171", "#EF4444", "#DC2626", "#B91C1C",
|
||||
];
|
||||
|
||||
const sectionStyle = {
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
};
|
||||
const headingStyle = { color: "var(--text-heading)" };
|
||||
const labelStyle = { color: "var(--text-secondary)" };
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
|
||||
export default function MelodySettings() {
|
||||
@@ -200,8 +195,10 @@ export default function MelodySettings() {
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* --- Languages Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Available Languages</h2>
|
||||
<section className="ui-section-card">
|
||||
<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">
|
||||
{settings.available_languages.map((code) => (
|
||||
<div
|
||||
@@ -237,8 +234,10 @@ export default function MelodySettings() {
|
||||
</section>
|
||||
|
||||
{/* --- Quick Colors Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Quick Selection Colors</h2>
|
||||
<section className="ui-section-card">
|
||||
<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">
|
||||
{settings.quick_colors.map((color) => (
|
||||
<div key={color} className="relative group">
|
||||
@@ -279,8 +278,10 @@ export default function MelodySettings() {
|
||||
</section>
|
||||
|
||||
{/* --- Duration Presets Section --- */}
|
||||
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Duration Presets (seconds)</h2>
|
||||
<section className="ui-section-card xl:col-span-2">
|
||||
<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">
|
||||
{settings.duration_values.map((val) => (
|
||||
<div
|
||||
@@ -317,8 +318,10 @@ export default function MelodySettings() {
|
||||
</section>
|
||||
|
||||
{/* --- Note Assignment Colors --- */}
|
||||
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-2" style={headingStyle}>Note Assignment Color Coding</h2>
|
||||
<section className="ui-section-card xl:col-span-2">
|
||||
<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}>
|
||||
Colors used in Composer, Playback, and View table dots. Click a bell to customize.
|
||||
</p>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../../api/client";
|
||||
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 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">
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Archetype Info</h2>
|
||||
<section className="ui-section-card">
|
||||
<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>
|
||||
<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} />
|
||||
</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} />
|
||||
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<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>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -295,8 +295,10 @@ export default function ArchetypeForm() {
|
||||
</section>
|
||||
|
||||
{isEdit && (
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__title">Build</h2>
|
||||
</div>
|
||||
<p className="text-sm mb-4" style={mutedStyle}>
|
||||
Save any changes above before building. Rebuilding will overwrite previous output.
|
||||
{hasUnsavedChanges && (
|
||||
@@ -402,7 +404,7 @@ export default function ArchetypeForm() {
|
||||
)}
|
||||
|
||||
{!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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,15 +11,6 @@ const ROLE_COLORS = {
|
||||
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 }) {
|
||||
return (
|
||||
<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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -101,6 +142,21 @@ export default function StaffDetail() {
|
||||
const canDelete = canEdit && member.id !== user?.sub;
|
||||
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 (
|
||||
<div>
|
||||
{/* Header */}
|
||||
@@ -116,11 +172,9 @@ export default function StaffDetail() {
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full"
|
||||
style={
|
||||
member.is_active
|
||||
style={member.is_active
|
||||
? { 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"}
|
||||
</span>
|
||||
@@ -143,11 +197,10 @@ export default function StaffDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Account Info */}
|
||||
<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)" }}>Account Information</h2>
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
{/* Account Info — full width */}
|
||||
<section className="ui-section-card mb-6">
|
||||
<h2 className="ui-section-card__header-title mb-4">Account Information</h2>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Field label="Name">{member.name}</Field>
|
||||
<Field label="Email">{member.email}</Field>
|
||||
<Field label="Role">
|
||||
@@ -158,11 +211,9 @@ export default function StaffDetail() {
|
||||
<Field label="Status">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full"
|
||||
style={
|
||||
member.is_active
|
||||
style={member.is_active
|
||||
? { 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"}
|
||||
</span>
|
||||
@@ -173,68 +224,135 @@ export default function StaffDetail() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Permissions */}
|
||||
{(member.role === "editor" || member.role === "user") && member.permissions && (
|
||||
<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="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 = 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)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access:</span>
|
||||
{member.permissions.mqtt ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Enabled</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Admin / SysAdmin notice */}
|
||||
{(member.role === "sysadmin" || member.role === "admin") && (
|
||||
<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>
|
||||
<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 access to all features and settings."
|
||||
? "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>
|
||||
)}
|
||||
|
||||
{/* Permission sections */}
|
||||
{showPerms && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Melodies */}
|
||||
<PermSection title="Melodies">
|
||||
<PermRow label="View" value={mel.view} />
|
||||
<PermRow label="Add" value={mel.add} />
|
||||
<PermRow label="Delete" value={mel.delete} />
|
||||
<PermRow label="Safe Edit" value={mel.safe_edit || mel.full_edit} />
|
||||
<PermRow label="Full Edit" value={mel.full_edit} />
|
||||
<PermRow label="Archetype Access" value={mel.archetype_access} />
|
||||
<PermRow label="Settings Access" value={mel.settings_access} />
|
||||
<PermRow label="Compose Access" value={mel.compose_access} />
|
||||
</PermSection>
|
||||
|
||||
{/* Devices */}
|
||||
<PermSection title="Devices">
|
||||
<PermRow label="View" value={dev.view} />
|
||||
<PermRow label="Add" value={dev.add} />
|
||||
<PermRow label="Delete" value={dev.delete} />
|
||||
<PermRow label="Safe Edit (Info)" value={dev.safe_edit || dev.full_edit} />
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* API Reference + MQTT */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<PermSection title="API Reference">
|
||||
<PermRow label="Access API Reference" value={apir.access} />
|
||||
</PermSection>
|
||||
<PermSection title="MQTT">
|
||||
<PermRow label="MQTT Access" value={mqtt.access} />
|
||||
</PermSection>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reset Password Dialog */}
|
||||
{showResetPw && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.5)" }}>
|
||||
|
||||
@@ -3,123 +3,278 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "melodies", label: "Melodies" },
|
||||
{ key: "devices", label: "Devices" },
|
||||
{ key: "app_users", label: "App Users" },
|
||||
{ key: "equipment", label: "Issues and Notes" },
|
||||
];
|
||||
// ─── Default permission sets ───────────────────────────────────────────────
|
||||
|
||||
const ACTIONS = ["view", "add", "edit", "delete"];
|
||||
|
||||
const DEFAULT_PERMS_EDITOR = {
|
||||
melodies: { view: true, add: true, edit: true, delete: true },
|
||||
devices: { view: true, add: true, edit: true, delete: true },
|
||||
app_users: { view: true, add: true, edit: true, delete: true },
|
||||
equipment: { view: true, add: true, edit: true, delete: true },
|
||||
mqtt: true,
|
||||
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 },
|
||||
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: { view: true, add: true, delete: true, safe_edit: true, full_edit: true },
|
||||
issues_notes: { view: true, add: true, delete: true, edit: true },
|
||||
mail: { view: true, compose: true, reply: 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 },
|
||||
};
|
||||
|
||||
const DEFAULT_PERMS_USER = {
|
||||
melodies: { view: true, add: false, edit: false, delete: false },
|
||||
devices: { view: true, add: false, edit: false, delete: false },
|
||||
app_users: { view: true, add: false, edit: false, delete: false },
|
||||
equipment: { view: true, add: false, edit: false, delete: false },
|
||||
mqtt: false,
|
||||
const USER_PERMS = {
|
||||
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, 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, delete: false, safe_edit: false, full_edit: false },
|
||||
issues_notes: { view: true, add: false, delete: false, edit: 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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
is_active: true,
|
||||
});
|
||||
const [permissions, setPermissions] = useState({ ...DEFAULT_PERMS_USER });
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", role: "user", is_active: true });
|
||||
const [permissions, setPermissions] = useState(deepClone(USER_PERMS));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
if (!isEdit) return;
|
||||
setLoading(true);
|
||||
api.get(`/staff/${id}`)
|
||||
.then((data) => {
|
||||
setForm({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: "",
|
||||
role: data.role,
|
||||
is_active: data.is_active,
|
||||
});
|
||||
setForm({ name: data.name, email: data.email, password: "", role: data.role, is_active: data.is_active });
|
||||
if (data.permissions) {
|
||||
setPermissions(data.permissions);
|
||||
const base = data.role === "editor" ? deepClone(EDITOR_PERMS) : deepClone(USER_PERMS);
|
||||
const merged = {};
|
||||
Object.keys(base).forEach((sec) => { merged[sec] = { ...base[sec], ...(data.permissions[sec] || {}) }; });
|
||||
setPermissions(merged);
|
||||
} else if (data.role === "editor") {
|
||||
setPermissions({ ...DEFAULT_PERMS_EDITOR });
|
||||
} else if (data.role === "user") {
|
||||
setPermissions({ ...DEFAULT_PERMS_USER });
|
||||
setPermissions(deepClone(EDITOR_PERMS));
|
||||
} else {
|
||||
setPermissions(deepClone(USER_PERMS));
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleRoleChange = (newRole) => {
|
||||
setForm((f) => ({ ...f, role: newRole }));
|
||||
if (newRole === "editor") {
|
||||
setPermissions({ ...DEFAULT_PERMS_EDITOR });
|
||||
} else if (newRole === "user") {
|
||||
setPermissions({ ...DEFAULT_PERMS_USER });
|
||||
}
|
||||
if (newRole === "editor") setPermissions(deepClone(EDITOR_PERMS));
|
||||
else if (newRole === "user") setPermissions(deepClone(USER_PERMS));
|
||||
};
|
||||
|
||||
const togglePermission = (section, action) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[action]: !prev[section][action],
|
||||
},
|
||||
}));
|
||||
const setPerm = (section, key, value) =>
|
||||
setPermissions((prev) => applyDependencies(section, key, value, prev));
|
||||
|
||||
const setSegmented = (section, viewKey, editKey, val) => {
|
||||
setPermissions((prev) => {
|
||||
const next = { ...prev, [section]: { ...prev[section] } };
|
||||
next[section][viewKey] = val !== "none";
|
||||
next[section][editKey] = val === "edit";
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMqtt = () => {
|
||||
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt }));
|
||||
const segVal = (section, viewKey, editKey) => {
|
||||
const s = permissions[section] || {};
|
||||
if (s[editKey]) return "edit";
|
||||
if (s[viewKey]) return "view";
|
||||
return "none";
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
};
|
||||
|
||||
const body = { name: form.name, email: form.email, role: form.role };
|
||||
if (isEdit) {
|
||||
body.is_active = form.is_active;
|
||||
if (form.role === "editor" || form.role === "user") {
|
||||
body.permissions = permissions;
|
||||
} else {
|
||||
body.permissions = null;
|
||||
}
|
||||
body.permissions = (form.role === "editor" || form.role === "user") ? permissions : null;
|
||||
await api.put(`/staff/${id}`, body);
|
||||
navigate(`/settings/staff/${id}`);
|
||||
} else {
|
||||
body.password = form.password;
|
||||
if (form.role === "editor" || form.role === "user") {
|
||||
body.permissions = permissions;
|
||||
}
|
||||
if (form.role === "editor" || form.role === "user") body.permissions = permissions;
|
||||
const result = await api.post("/staff", body);
|
||||
navigate(`/settings/staff/${result.id}`);
|
||||
}
|
||||
@@ -134,18 +289,36 @@ export default function StaffForm() {
|
||||
|
||||
const roleOptions = user?.role === "sysadmin"
|
||||
? ["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 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 (
|
||||
<div>
|
||||
<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)" }}>
|
||||
← {isEdit ? "Back to Staff Member" : "Back to Staff"}
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
@@ -154,56 +327,29 @@ export default function StaffForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||
{/* Basic Info */}
|
||||
<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)" }}>Account Information</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* ── Account Information ── */}
|
||||
<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>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required className={inputClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required className={inputClass} style={inputStyle} />
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label>
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => handleRoleChange(e.target.value)}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
style={inputStyle}
|
||||
>
|
||||
<select value={form.role} onChange={(e) => handleRoleChange(e.target.value)} className={`${inputClass} cursor-pointer`} style={inputStyle}>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
||||
))}
|
||||
@@ -212,12 +358,7 @@ export default function StaffForm() {
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label>
|
||||
<select
|
||||
value={form.is_active ? "active" : "inactive"}
|
||||
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
style={inputStyle}
|
||||
>
|
||||
<select 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="inactive">Inactive</option>
|
||||
</select>
|
||||
@@ -226,77 +367,234 @@ export default function StaffForm() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Permissions Matrix - only for editor/user */}
|
||||
{(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>
|
||||
)}
|
||||
|
||||
{/* ── Admin / SysAdmin notice ── */}
|
||||
{(form.role === "sysadmin" || form.role === "admin") && (
|
||||
<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>
|
||||
<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)" }}>
|
||||
{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."}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
{/* ── Permission Sections ── */}
|
||||
{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
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
@@ -314,6 +612,7 @@ export default function StaffForm() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,7 @@ import NotesPanel from "../equipment/NotesPanel";
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<dt
|
||||
className="text-xs font-medium uppercase tracking-wide"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</dt>
|
||||
<dt className="ui-field-label">{label}</dt>
|
||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
|
||||
{children || "-"}
|
||||
</dd>
|
||||
@@ -255,16 +250,10 @@ export default function UserDetail() {
|
||||
{/* Left column */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Info */}
|
||||
<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)" }}
|
||||
>
|
||||
Account Information
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Account Information</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "1.5rem" }}>
|
||||
{/* Profile Photo */}
|
||||
<div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
|
||||
@@ -336,32 +325,20 @@ export default function UserDetail() {
|
||||
</section>
|
||||
|
||||
{/* Profile */}
|
||||
<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)" }}
|
||||
>
|
||||
Profile
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Profile</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 gap-4">
|
||||
<Field label="Bio">{user.bio}</Field>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Timestamps */}
|
||||
<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)" }}
|
||||
>
|
||||
Activity
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Activity</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Created Time">{user.created_time}</Field>
|
||||
<Field label="Created At">{user.createdAt}</Field>
|
||||
@@ -373,16 +350,10 @@ export default function UserDetail() {
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
{/* Security */}
|
||||
<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)" }}
|
||||
>
|
||||
Security
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Security</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<Field label="Settings PIN">{user.settingsPIN ? "****" : "-"}</Field>
|
||||
<Field label="Quick Settings PIN">{user.quickSettingsPIN ? "****" : "-"}</Field>
|
||||
@@ -390,16 +361,10 @@ export default function UserDetail() {
|
||||
</section>
|
||||
|
||||
{/* Friends */}
|
||||
<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)" }}
|
||||
>
|
||||
Friends
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Friends</h2>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<Field label="Friends">
|
||||
{user.friendsList?.length ?? 0}
|
||||
@@ -411,17 +376,9 @@ export default function UserDetail() {
|
||||
</section>
|
||||
|
||||
{/* Assigned Devices */}
|
||||
<section
|
||||
className="rounded-lg border p-6"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<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>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Assigned Devices ({devices.length})</h2>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={openAssignPanel}
|
||||
|
||||
@@ -87,6 +87,11 @@ export default function UserForm() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -129,16 +134,13 @@ export default function UserForm() {
|
||||
{/* ===== Left Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Account Info --- */}
|
||||
<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)" }}>
|
||||
Account Information
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Account Information</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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 *
|
||||
</label>
|
||||
<input
|
||||
@@ -147,10 +149,11 @@ export default function UserForm() {
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
@@ -159,22 +162,24 @@ export default function UserForm() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
type="text"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="e.g. +1234567890"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
UID
|
||||
</label>
|
||||
<input
|
||||
@@ -182,38 +187,49 @@ export default function UserForm() {
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
className={inputClass}
|
||||
style={isEdit ? { ...inputStyle, opacity: 0.5 } : inputStyle}
|
||||
disabled={isEdit}
|
||||
style={isEdit ? { opacity: 0.5 } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className={inputClass}
|
||||
<label className="ui-form-label">Status</label>
|
||||
<div style={{ display: "flex", borderRadius: 6, overflow: "hidden", border: "1px solid var(--border-input)" }}>
|
||||
{[
|
||||
{ value: "active", label: "Active", activeColor: "#31ee76", activeBg: "#14532d" },
|
||||
{ value: "blocked", label: "Blocked", activeColor: "#f34b4b", activeBg: "#3b1a1a" },
|
||||
].map((opt, idx) => {
|
||||
const isActive = status === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<option value="">Select status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Profile --- */}
|
||||
<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)" }}>
|
||||
Profile
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Profile</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
@@ -222,10 +238,11 @@ export default function UserForm() {
|
||||
onChange={(e) => setUserTitle(e.target.value)}
|
||||
placeholder="e.g. Church Administrator"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Photo URL
|
||||
</label>
|
||||
<input
|
||||
@@ -233,10 +250,11 @@ export default function UserForm() {
|
||||
value={photoUrl}
|
||||
onChange={(e) => setPhotoUrl(e.target.value)}
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<textarea
|
||||
@@ -244,7 +262,7 @@ export default function UserForm() {
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={3}
|
||||
className={inputClass}
|
||||
style={{ resize: "vertical" }}
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,36 +272,37 @@ export default function UserForm() {
|
||||
{/* ===== Right Column ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* --- Security --- */}
|
||||
<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)" }}>
|
||||
Security
|
||||
</h2>
|
||||
<section className="ui-section-card">
|
||||
<div className="ui-section-card__title-row">
|
||||
<h2 className="ui-section-card__header-title">Security</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Settings PIN
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settingsPIN}
|
||||
onChange={(e) => setSettingsPIN(e.target.value)}
|
||||
onChange={(e) => setSettingsPIN(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="e.g. 1234"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
<label className="ui-form-label">
|
||||
Quick Settings PIN
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={quickSettingsPIN}
|
||||
onChange={(e) => setQuickSettingsPIN(e.target.value)}
|
||||
onChange={(e) => setQuickSettingsPIN(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="e.g. 0000"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# WebSocket support for MQTT live data
|
||||
|
||||